commit a6077dd357c07c548ba8ecf50bc0a7126ca66e3a Author: dev Date: Fri Apr 24 03:31:51 2026 +0200 Initial commit: lang + logr libraries with SvelteKit scaffold lang — type-safe i18n engine (zero-deps): - Pure engine (createEngineLang) with explicit locale per call - Svelte 5 reactive wrapper (createActiveLang) via $state - Fallback chain, Intl.PluralRules, cross-key references, JSON round-trip - Type-safe paths with inference from schema logr — structured logger (zero-deps, decoupled from i18n): - 6 levels (TRACE..FATAL) aligned with pino/Log4j/OpenTelemetry/Sentry - Auto-generated entry.id (UUID v4 / hex fallback) - globalContext, child(), time/timeEnd, lazy messages, source capture - Dynamic transports with per-sink minLevel/filter, buffer + writeBatch - Hybrid dispatch: sync loop, transports may be async - Failure routing with deniedFor and cascade guard - Adapters: console, http, callback, sentry, datadog, logtail, loki, otel Test pages at /test/lang and /test/logr. 180 unit tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67c0123 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +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-* + +# IDE / tool state (personal, not shared) +.claude/settings.local.json +.idea/workspace.xml +.idea/shelf/ +.idea/tasks.xml +.idea/usage.statistics.xml +.idea/dictionaries/ +.idea/libraries/ +.idea/dataSources/ +.idea/dataSources.local.xml +.idea/dynamic.xml +.idea/uiDesigner.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/active.iml b/.idea/active.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/active.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..5055868 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ca5f74a --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 0000000..b0c1c68 --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /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 0000000..3f7802c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..4d5e64f --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..0014edd --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,44 @@ +import prettier from 'eslint-config-prettier'; +import path from 'node:path'; +import { includeIgnoreFile } from '@eslint/compat'; +import js from '@eslint/js'; +import svelte from 'eslint-plugin-svelte'; +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; +import ts from 'typescript-eslint'; +import svelteConfig from './svelte.config.js'; + +const gitignorePath = path.resolve(import.meta.dirname, '.gitignore'); + +export default defineConfig( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ts.configs.recommended, + svelte.configs.recommended, + prettier, + svelte.configs.prettier, + { + languageOptions: { globals: { ...globals.browser, ...globals.node } }, + rules: { + // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + 'no-undef': 'off' + } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig + } + } + }, + { + // Override or add rule settings here, such as: + // 'svelte/button-has-type': 'error' + rules: {} + } +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f15edc4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3487 @@ +{ + "name": "active", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "active", + "version": "0.0.1", + "dependencies": { + "@sentry/browser": "^10.50.0" + }, + "devDependencies": { + "@eslint/compat": "^2.0.4", + "@eslint/js": "^10.0.1", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@types/node": "^22", + "@vitest/browser-playwright": "^4.1.3", + "eslint": "^10.2.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.17.0", + "globals": "^17.4.0", + "playwright": "^1.59.1", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.1", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.1", + "vite": "^8.0.7", + "vitest": "^4.1.3", + "vitest-browser-svelte": "^2.1.0" + } + }, + "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/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/compat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.5.tgz", + "integrity": "sha512-IbHDbHJfkVNv6xjlET8AIVo/K1NQt7YT4Rp6ok/clyBGcpRx1l6gv0Rq3vBvYfPJIZt6ODf66Zq08FJNDpnzgg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^8.40 || 9 || 10" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "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/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.50.0.tgz", + "integrity": "sha512-42bxyRTxnCmYlWnvz4CxikuQNanw8UNma2WJrtxJ0f1MAJV2GhQGSHDLnA+lvFlmiz6qct3pfen/NXGyOTegTA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.50.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.50.0.tgz", + "integrity": "sha512-0k9XZF0wn86f77mIO2U3gNNyDZooy139CnEanRzHinrN106vVzvBZ6TUEQoHtoO1fqQxr+nWWVrqV/PXUqk47w==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.50.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.50.0.tgz", + "integrity": "sha512-51FYNfnvVLAWw1rrEWPFfwHuMRb9mkVCFGA4J9/un7SpeGBsQDziGB0Di4fsCxI7+EdSBpfLHPF0csKtCCw0oQ==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.50.0", + "@sentry/core": "10.50.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.50.0.tgz", + "integrity": "sha512-jx6RKBmcJSWdI92qDGS/sBv1w+7Cww879Z/moX7bw7ipHa/Ts3iDcB3rgZwvhmi17U+mvYsbJeL2DXkPo3TjPw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.50.0", + "@sentry/core": "10.50.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.50.0.tgz", + "integrity": "sha512-1f6rAvET6myiTaSeYqvaaBwvq1LfxqWjAPIoAW/NVC9bPMkeEcuvgDajHrnZMrBeWoJ81NMyoLkyX+iOc7MoFA==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.50.0", + "@sentry-internal/feedback": "10.50.0", + "@sentry-internal/replay": "10.50.0", + "@sentry-internal/replay-canvas": "10.50.0", + "@sentry/core": "10.50.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.50.0.tgz", + "integrity": "sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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.57.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz", + "integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==", + "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 || ^6.0.0", + "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": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", + "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "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/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.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/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "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/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.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/eslint-plugin": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "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/@typescript-eslint/typescript-estree": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "eslint-visitor-keys": "^5.0.0" + }, + "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.5", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.5.tgz", + "integrity": "sha512-iCDGI8c4yg+xmjUg2VsygdAUSIIB4x5Rht/P68OXy1hPELKXHDkzh87lkuTcdYmemRChDkEpB426MmDjzC0ziA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.5" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.5.tgz", + "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.5", + "@vitest/mocker": "4.1.5", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "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.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "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/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "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/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "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.7.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", + "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", + "dev": true, + "license": "MIT" + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "3.17.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.17.1.tgz", + "integrity": "sha512-NyiXHtS3Ni7e532RBwS9OXlMKDIrENg3gY+/+ODjZzQx2xhU3NlJ+nIl1a93iUUQeiJL3lS8KLmY+W8hklzweQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", + "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.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/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.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/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "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/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "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/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "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/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "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.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "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-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "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/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.55.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz", + "integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==", + "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.4", + "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.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz", + "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==", + "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/svelte-eslint-parser": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz", + "integrity": "sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.30.3" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "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/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", + "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.0", + "@typescript-eslint/parser": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "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", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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 + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "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.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "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" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "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.1.0", + "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.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "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/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest-browser-svelte": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-2.1.1.tgz", + "integrity": "sha512-qbunYRSm+N92r9bfTkdDTpBZESLmp4QFz2SluV3n/x8U7ysosfeXYJZ4vXbJ0Y0LzoqqDnV5LHprmFgn4Eo+Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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 0000000..4cf3ac6 --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "active", + "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 . && eslint .", + "format": "prettier --write .", + "test:unit": "vitest", + "test": "npm run test:unit -- --run" + }, + "devDependencies": { + "@eslint/compat": "^2.0.4", + "@eslint/js": "^10.0.1", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@types/node": "^22", + "@vitest/browser-playwright": "^4.1.3", + "eslint": "^10.2.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.17.0", + "globals": "^17.4.0", + "playwright": "^1.59.1", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.1", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.1", + "vite": "^8.0.7", + "vitest": "^4.1.3", + "vitest-browser-svelte": "^2.1.0" + }, + "dependencies": { + "@sentry/browser": "^10.50.0" + } +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /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 0000000..6a2bb58 --- /dev/null +++ b/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/arts/lang/README.md b/src/arts/lang/README.md new file mode 100644 index 0000000..7d058b9 --- /dev/null +++ b/src/arts/lang/README.md @@ -0,0 +1,351 @@ +# lang + +Type-safe i18n library for SvelteKit. **Zero external dependencies.** Reactive +translation resolution with fallback chain, pluralization via +`Intl.PluralRules`, cross-key references, and full JSON serialization. + +--- + +## Architecture + +``` +lang/ +├── index.ts Barrel exports +├── types.ts LangRecord, LangNode, EngineLangInstance, ActiveLangInstance, ... +├── engine-lang.ts Pure factory: createEngineLang() +├── active-lang.svelte.ts Svelte 5 reactive wrapper: createActiveLang() +├── plural.ts p() helper, WeakMap-based config storage +├── plural_rules.ts Intl.PluralRules wrapper +├── guards.ts Type guards: isLangRef, isLangRecord, isLangString +├── helpers.ts resolvePath, interpolateTemplate, resolveRecordFallback, deepMerge +├── json.ts langNodeToJSON / JSONToLangNode (round-trip) +├── consts.ts ID_PREFIX (#?), separators, limits +├── errors.ts Centralized error messages +└── test/ + └── lang.test.ts Unit tests (vitest, node env) +``` + +### Design principles + +1. **Pure engine** — `createEngineLang()` has no reactive state of its own. + `t()` and `ts()` **require** an explicit `locale`. No hidden mutation. +2. **Reactive wrapper** — `createActiveLang()` (in `.svelte.ts`) owns the + locale via `$state`, injecting it into each engine call. The engine stays + framework-agnostic so non-Svelte consumers can build their own adapter. +3. **Fallback chain** — `[currentLocale, ...fallbackChain, defaultLocale]`. +4. **Type-safe paths** — `t('common.ok')` autocompletes and validates types. + A dynamic `string` overload is available for runtime paths. +5. **Decoupled plural data** — `p()` lives in `plural.ts`, not in the engine; + translation data never imports from the engine. + +--- + +## Alias + +Configured in `svelte.config.js`: + +```js +alias: { $lang: 'src/arts/lang' } +``` + +Imports: + +```ts +import { createEngineLang } from '$lang'; +import { createActiveLang } from '$lang/active-lang.svelte'; +``` + +--- + +## Schema + +A schema is a tree of `LangNode` with four leaf types: + +### LangRecord — static translations + +```ts +{ es: 'Aceptar', en: 'OK', ar: 'حسناً' } +``` + +All locale keys are optional — no locale is hardcoded. + +### LangFn — parametric interpolation + +```ts +(params: { name: string }) => ({ + es: `Hola, ${params.name}`, + en: `Hello, ${params.name}` +}); +``` + +At serialization time (`langNodeToJSON`), a proxy captures the tokens so the +JSON contains `"Hola, {{name}}"` regardless of whether the function uses +`${...}` or `{{...}}`. + +### LangPluralFn — pluralization with `p()` + +```ts +import { p } from '$lang'; + +p({ + es: { one: '{{count}} mensaje', other: '{{count}} mensajes' }, + en: { one: '{{count}} message', other: '{{count}} messages' }, + ar: { + zero: 'لا توجد رسائل', + one: 'رسالة واحدة', + two: 'رسالتان', + few: '{{count}} رسائل', + many: '{{count}} رسالة', + other: '{{count}} رسالة' + } +}); +``` + +Internally uses `Intl.PluralRules`. Forms: `zero`, `one`, `two`, `few`, +`many`, `other`. Only `other` is required; missing forms fall back to +`other`. + +### LangRef — alias to another key + +```ts +'#?common.ok'; // resolves the value at `common.ok` +'#?common.ok|fallback'; // literal fallback if the key does not exist +``` + +Prefix `#?`, separator `|` for the fallback literal. Up to 3 levels of +indirection; cycles throw. + +### Full example + +```ts +import { p } from '$lang'; +import type { LangNode } from '$lang'; + +export const translations = { + common: { + ok: { es: 'Aceptar', en: 'OK' }, + cancel: { es: 'Cancelar', en: 'Cancel' } + }, + greet: (params: { name: string }) => ({ + es: `Hola, ${params.name}`, + en: `Hello, ${params.name}` + }), + messages: { + unread: p({ + es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' }, + en: { one: '{{count}} unread message', other: '{{count}} unread messages' } + }) + }, + ref: '#?common.ok' +} satisfies LangNode; + +export type TranslationSchema = typeof translations; +``` + +--- + +## Usage + +### Pure engine (non-Svelte) + +```ts +import { createEngineLang } from '$lang'; +import { translations } from './translations'; + +const lang = createEngineLang(translations, 'es'); + +// `t()` and `ts()` require an explicit locale on the pure engine. +lang.t('common.ok', undefined, 'es'); // 'Aceptar' +lang.t('greet', { name: 'Ana' }, 'en'); // 'Hello, Ana' +lang.t('messages.unread', { count: 5 }, 'en'); // '5 unread messages' + +lang.ts({ es: 'Hola', en: 'Hello' }, 'en'); // 'Hello' +lang.ts('#?common.ok', 'es'); // 'Aceptar' +``` + +### Reactive wrapper for Svelte 5 + +```svelte + + +

{lang.t('common.ok')}

+ + +``` + +`createActiveLang` holds `locale` in `$state`. When you call +`setLocale(x)`, every `t()` / `ts()` / `getLocale()` consumed in a +component (or inside `$derived` / `$effect`) re-evaluates. + +Signatures on the reactive wrapper make `locale` **optional** — it defaults +to the active reactive locale: + +```ts +lang.t('common.ok'); // uses active locale +lang.t('common.ok', undefined, 'en'); // explicit locale override +``` + +### Fallback chain + +```ts +// Resolution order: currentLocale → en → es (default) +const lang = createActiveLang(translations, 'es', ['en']); + +lang.setLocale('fr'); +lang.t('common.ok'); // fr missing → tries en → 'OK' +``` + +### Dynamic modules — `extend()` + +Adds translations to the schema at runtime. Supports dotted namespaces and +deep-merges existing keys: + +```ts +lang.extend('shop', { + product: { es: 'Producto', en: 'Product' } +}); + +lang.t('shop.product'); // 'Producto' + +// Dotted namespace — creates nested structure +lang.extend('app.settings', { + title: { es: 'Ajustes', en: 'Settings' } +}); + +// Subscribe to schema changes (triggers re-render in Svelte automatically) +const unsub = lang.onSchemaChange(() => { + // schema changed +}); +``` + +### Child instances — `register()` + +Creates a new instance with the module merged. In the reactive wrapper the +child shares locale state with the parent: + +```ts +const child = lang.register('admin', { + dashboard: { es: 'Panel', en: 'Dashboard' } +}); + +child.t('admin.dashboard'); // 'Panel' +child.t('common.ok'); // 'Aceptar' (inherits parent schema) + +lang.setLocale('en'); +child.getLocale(); // 'en' (synced) + +child.dispose(); // detach from parent +lang.setLocale('es'); +child.getLocale(); // 'en' (stopped syncing) +``` + +--- + +## API — EngineLangInstance + +| Method | Description | +| ------------------------------------ | ----------------------------------------------------------- | +| `t(path, params, locale)` | Translate by schema path. Params for interpolation/plural. | +| `ts(value, locale)` | Translate a direct `LangString` (record, string or ref). | +| `onSchemaChange(fn)` | Subscribe to schema mutations (`extend`). Returns unsub. | +| `extend(namespace, module)` | Add a module at runtime via deep-merge. Dotted namespaces. | +| `register(namespace, module)` | Return a child instance with the merged module. | +| `setLogger(logger)` | Inject a custom logger. Set-once. | +| `getDefaultLocale()` | Return the default locale of the instance. | +| `getFallbackChain()` | Return the configured fallback chain (or `undefined`). | + +## API — ActiveLangInstance (Svelte) + +In addition to the above (with `locale` made optional on `t` / `ts`): + +| Method | Description | +| ------------------------------------ | ----------------------------------------------------------- | +| `getLocale()` | Return the active locale (reactive). | +| `setLocale(locale)` | Change the active locale. | +| `onLocaleChange(fn)` | Subscribe to locale changes. Returns unsub. | +| `dispose()` | Detach a child from its parent locale stream. | + +--- + +## Types + +| Type | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------- | +| `SupportedLocale` | Union: `es \| en \| ar \| de \| fr \| it \| pt \| ca \| eu \| gl` | +| `LangRecord` | `{ [K in SupportedLocale]?: string }` — all optional | +| `LangString` | `string \| LangRecord \| LangRef` | +| `LangNode` | Recursive: `LangValue \| LangBranch` | +| `LangValue

` | `LangRecord \| LangFn

\| LangPluralFn

\| LangRef` | +| `LangBranch` | `{ [key: string]: LangNode }` — named branch | +| `LangRef` | Template literal: `` `#?${string}` `` | +| `LangFn

` | `(params: P) => LangRecord` | +| `LangPluralFn

` | `(params: { count: number } & P) => LangRecord` | +| `LangParams` | `Record` — canonical params type | +| `PluralConfig` | `{ [K in SupportedLocale]?: PluralForms }` | +| `PluralForms` | `{ other: string } & Partial>` | +| `EngineLangInstance` | Pure engine public interface | +| `ActiveLangInstance` | Reactive wrapper public interface | +| `LangLogger` | `{ warn, error }` — custom logger interface | + +--- + +## Fallback behavior + +When a key is translated, locales are tried in this order: + +``` +1. currentLocale +2. fallbackChain[0] +3. fallbackChain[1] +4. ... +5. defaultLocale +``` + +Example with `createEngineLang(schema, 'es', ['en'])` and +`setLocale('fr')`: + +``` +key: common.ok → { es: 'Aceptar', en: 'OK' } + +1. fr → missing +2. en → 'OK' ✓ +``` + +If every locale is missing, `t()` returns the path (or the `|fallback` +literal if present) and `ts()` returns the empty string. In DEV a warning +is emitted when a fallback is used. + +--- + +## JSON round-trip + +```ts +import { langNodeToJSON, JSONToLangNode } from '$lang'; + +const json = langNodeToJSON(translations); +// LangFn → { es: 'Hola {{name}}', en: 'Hello {{name}}' } +// LangPluralFn → { __type: 'plural', config: { ... } } +// LangRef → '#?common.ok' (plain string) +// LangRecord → { es: 'Aceptar', en: 'OK' } (unchanged) + +const restored = JSONToLangNode(json); +// Interpolation and plural functions are reconstructed. +// References stay as strings (isLangRef() recognises them). +``` + +--- + +## Tests + +```bash +npx vitest run src/arts/lang/test/lang.test.ts +``` + +Covers: guards, helpers, plural rules, `p()` with WeakMap, engine (t/ts, +locale param, fallback chain, extend, register, setLogger), JSON +round-trip, and confirmation that no locale is hardcoded. diff --git a/src/arts/lang/active-lang.svelte.ts b/src/arts/lang/active-lang.svelte.ts new file mode 100644 index 0000000..24cf5b3 --- /dev/null +++ b/src/arts/lang/active-lang.svelte.ts @@ -0,0 +1,103 @@ +import { SvelteSet } from 'svelte/reactivity'; +import { + type ActiveLangInstance, + createEngineLang, + type EngineLangInstance, + type LangLogger, + type LangNode, + type LangParams, + type LangString, + type SupportedLocale +} from './index'; + +export type { LangNode, LangParams, LangString, SupportedLocale, ActiveLangInstance }; + +/** + * Reactive wrapper around the pure lang engine for Svelte 5. + * + * The engine is stateless regarding locale — this wrapper owns the + * `_locale` `$state` and injects it into every `t()`/`ts()` call. + * Any `$derived` / `$effect` touching `getLocale()`, `t()` or `ts()` + * re-evaluates when the locale changes. + */ +export function createActiveLang( + schema: S, + defaultLocale: SupportedLocale = 'es', + fallbackChain?: SupportedLocale[] +): ActiveLangInstance { + const engine = createEngineLang(schema, defaultLocale, fallbackChain); + return wrapEngine(engine, defaultLocale); +} + +function wrapEngine( + engine: EngineLangInstance, + initialLocale: SupportedLocale, + parentOnLocaleChange?: (fn: (locale: SupportedLocale) => void) => () => void +): ActiveLangInstance { + let _locale = $state(initialLocale); + let _schemaVersion = $state(0); + + const localeListeners = new SvelteSet<(locale: SupportedLocale) => void>(); + + const unsubSchema = engine.onSchemaChange(() => { + _schemaVersion++; + }); + + const unsubParent = parentOnLocaleChange?.((locale) => { + setLocale(locale); + }); + + function setLocale(locale: SupportedLocale): void { + if (_locale === locale) return; + _locale = locale; + localeListeners.forEach((fn) => fn(locale)); + } + + function onLocaleChange(fn: (locale: SupportedLocale) => void): () => void { + localeListeners.add(fn); + return () => localeListeners.delete(fn); + } + + return { + t(path: string, params?: LangParams, locale?: SupportedLocale): string { + void _schemaVersion; + return engine.t(path, params, locale ?? _locale); + }, + + ts(value: LangString, locale?: SupportedLocale): string { + void _schemaVersion; + return engine.ts(value, locale ?? _locale); + }, + + getLocale(): SupportedLocale { + return _locale; + }, + + setLocale, + + onLocaleChange, + + onSchemaChange(fn: () => void): () => void { + return engine.onSchemaChange(fn); + }, + + extend(namespace: string, module: LangNode): void { + engine.extend(namespace, module); + }, + + register(namespace: NS, module: M) { + const childEngine = engine.register(namespace, module); + return wrapEngine(childEngine, _locale, onLocaleChange); + }, + + setLogger(logger: LangLogger): void { + engine.setLogger(logger); + }, + + dispose(): void { + unsubSchema(); + unsubParent?.(); + localeListeners.clear(); + } + } as ActiveLangInstance; +} diff --git a/src/arts/lang/consts.ts b/src/arts/lang/consts.ts new file mode 100644 index 0000000..a95d73b --- /dev/null +++ b/src/arts/lang/consts.ts @@ -0,0 +1,5 @@ + +export const ID_PREFIX = '#?'; +export const ID_FALLBACK_SEPARATOR = '|'; +export const MAX_RESOLVE_DEEP = 3; +export const LOGGER_CATEGORY = 'lang'; diff --git a/src/arts/lang/engine-lang.ts b/src/arts/lang/engine-lang.ts new file mode 100644 index 0000000..4087fa1 --- /dev/null +++ b/src/arts/lang/engine-lang.ts @@ -0,0 +1,240 @@ +import type { + SupportedLocale, + LangBranch, + LangFn, + LangParams, + LangRecord, + LangString, + EngineLangInstance, + LangLogger, + LangNode +} from './types.ts'; +import { LANG_ERRORS } from './errors.ts'; +import { ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEEP } from './consts.ts'; +import { isLangRef, isLangRecord } from './guards.ts'; +import { + interpolateTemplate, + resolvePath, + parseLangRef, + parsePathFallback, + deepMerge +} from './helpers.ts'; + +const DEV: boolean = + typeof import.meta !== 'undefined' && import.meta.env != null + ? import.meta.env.DEV + : typeof process !== 'undefined' && process.env?.NODE_ENV === 'development'; + +export function createEngineLang( + schema: S, + defaultLocale: SupportedLocale = 'es', + fallbackChain?: SupportedLocale[] +): EngineLangInstance { + let currentSchema: LangNode = schema; + let logger: LangLogger = consoleLogger; + let loggerSet: boolean = false; + + const schemaListeners = new Set<() => void>(); + + function setLogger(external: LangLogger): void { + if (loggerSet) { + if (DEV) console.warn(LANG_ERRORS.LOGGER_ALREADY_SET); + return; + } + logger = external; + loggerSet = true; + } + + function buildChain(locale: SupportedLocale): SupportedLocale[] { + if (locale === defaultLocale) return [defaultLocale]; + const chain: SupportedLocale[] = [locale]; + for (const loc of fallbackChain ?? []) { + if (loc !== locale && loc !== defaultLocale) chain.push(loc); + } + chain.push(defaultLocale); + return chain; + } + + function tsRecord( + record: LangRecord, + locale: SupportedLocale, + path?: string, + params?: LangParams + ): string { + const chain = buildChain(locale); + let translation: string | undefined; + let usedLocale: SupportedLocale | undefined; + + for (const loc of chain) { + const candidate = record[loc]; + if (candidate !== undefined) { + translation = candidate; + usedLocale = loc; + break; + } + } + + if (translation === undefined) translation = path ?? ''; + + translation = interpolateTemplate(translation, params); + + if (DEV && usedLocale !== undefined && usedLocale !== locale) { + const msg = path + ? LANG_ERRORS.MISSING_TRANSLATION(path, locale, usedLocale) + : LANG_ERRORS.MISSING_TRANSLATION_RECORD(locale, usedLocale); + logger.warn(LOGGER_CATEGORY, msg); + } + + return translation; + } + + type ResolvedLeaf = string | LangRecord | undefined; + + function resolveValue( + value: LangNode | string | undefined, + params: unknown, + locale: SupportedLocale, + depth: number, + visited?: Set + ): ResolvedLeaf { + if (value === undefined) return undefined; + + if (depth > MAX_RESOLVE_DEEP) { + logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(String(value))); + throw new Error('Circular reference in lang'); + } + + if (isLangRef(value)) { + const parsed = parseLangRef(value); + const path = parsed?.path ?? value.substring(ID_PREFIX.length); + const seen = visited ?? new Set(); + if (seen.has(path)) { + logger.error(LOGGER_CATEGORY, LANG_ERRORS.CIRCULAR_REFERENCE(path)); + throw new Error('Circular reference in lang'); + } + seen.add(path); + const resolved = resolvePath(currentSchema, path); + + if (resolved === undefined) { + if (parsed?.fallback !== undefined) { + if (DEV) + logger.warn( + LOGGER_CATEGORY, + `${LANG_ERRORS.KEY_NOT_FOUND(path)}. Using fallback "${parsed.fallback}".` + ); + return parsed.fallback; + } + return undefined; + } + + return resolveValue(resolved, params, locale, depth + 1, seen); + } + + if (typeof value === 'function') { + return (value as LangFn)(params); + } + + if (isLangRecord(value)) return value; + + // Plain string or unexpected shape (e.g. raw LangBranch) — no valid translation. + return typeof value === 'string' ? value : undefined; + } + + const t: EngineLangInstance['t'] = ( + path: string, + params: LangParams | undefined, + locale: SupportedLocale + ): string => { + const { path: cleanPath, fallback: pathFallback } = parsePathFallback(path); + const rawValue = resolvePath(currentSchema, cleanPath); + + if (rawValue === undefined) { + if (pathFallback !== undefined) { + return interpolateTemplate(pathFallback, params); + } + if (DEV) logger.error(LOGGER_CATEGORY, LANG_ERRORS.KEY_NOT_FOUND(cleanPath)); + return cleanPath; + } + + const finalValue = resolveValue(rawValue, params, locale, 0); + + if (isLangRecord(finalValue)) { + return tsRecord(finalValue, locale, cleanPath, params); + } + + return finalValue ?? cleanPath; + }; + + function ts(value: LangString, locale: SupportedLocale): string { + if (value == null) return ''; + const finalValue = resolveValue(value, undefined, locale, 0); + if (isLangRecord(finalValue)) return tsRecord(finalValue, locale, undefined, undefined); + return typeof finalValue === 'string' ? finalValue : ''; + } + + function extend(namespace: string, module: LangNode): void { + const parts = namespace.split('.'); + let nested: LangNode = module; + for (let i = parts.length - 1; i > 0; i--) { + nested = { [parts[i]]: nested }; + } + const root = parts[0]; + const schemaBranch = currentSchema as LangBranch; + const existing = schemaBranch[root]; + const merged: LangNode = isPlainBranch(existing) && isPlainBranch(nested) + ? deepMerge(existing, nested) + : nested; + currentSchema = { ...schemaBranch, [root]: merged }; + schemaListeners.forEach((fn) => fn()); + } + + function register( + namespace: NS, + module: M + ): EngineLangInstance { + const newSchema = { + ...(currentSchema as LangBranch), + [namespace]: module + } as S & { [K in NS]: M }; + + return createEngineLang(newSchema, defaultLocale, fallbackChain); + } + + function onSchemaChange(fn: () => void): () => void { + schemaListeners.add(fn); + return () => schemaListeners.delete(fn); + } + + function getDefaultLocale(): SupportedLocale { + return defaultLocale; + } + + function getFallbackChain(): SupportedLocale[] | undefined { + return fallbackChain; + } + + return { + t, + ts, + onSchemaChange, + extend, + register, + setLogger, + getDefaultLocale, + getFallbackChain + }; +} + +function isPlainBranch(value: unknown): value is LangBranch { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof value !== 'function' + ); +} + +const consoleLogger: LangLogger = { + warn: (_category: string, message: string) => DEV && console.warn(message), + error: (_category: string, message: string) => DEV && console.error(message) +}; diff --git a/src/arts/lang/errors.ts b/src/arts/lang/errors.ts new file mode 100644 index 0000000..ca59cf5 --- /dev/null +++ b/src/arts/lang/errors.ts @@ -0,0 +1,13 @@ +export const LANG_ERRORS = { + KEY_NOT_FOUND: (path: string): string => `[lang] Translation key not found: "${path}"`, + + CIRCULAR_REFERENCE: (path: string): string => `[lang] Circular reference in "${path}".`, + + MISSING_TRANSLATION: (path: string, locale: string, usedLocale: string): string => + `[lang] Missing translation for "${path}" in "${locale}". Falling back to "${usedLocale}".`, + + MISSING_TRANSLATION_RECORD: (locale: string, usedLocale: string): string => + `[lang] Missing translation in "${locale}". Falling back to "${usedLocale}".`, + + LOGGER_ALREADY_SET: '[lang] Logger already set. setLogger() can only be called once.' +} as const; diff --git a/src/arts/lang/guards.ts b/src/arts/lang/guards.ts new file mode 100644 index 0000000..c04ad99 --- /dev/null +++ b/src/arts/lang/guards.ts @@ -0,0 +1,19 @@ +import type { LangRef, LangRecord, LangString } from './types.ts'; +import { ID_PREFIX } from './consts.ts'; + +export function isLangRef(value: unknown): value is LangRef { + return typeof value === 'string' && value.startsWith(ID_PREFIX) && value.length > 2; +} + +export function isLangRecord(value: unknown): value is LangRecord { + if (typeof value !== 'object' || value === null) return false; + if (Array.isArray(value)) return false; + if (typeof value === 'function') return false; + const vals = Object.values(value as Record); + if (vals.length === 0) return false; + return vals.every((v) => typeof v === 'string' || v === undefined); +} + +export function isLangString(value: unknown): value is LangString { + return isLangRef(value) || isLangRecord(value) || typeof value === 'string'; +} diff --git a/src/arts/lang/helpers.ts b/src/arts/lang/helpers.ts new file mode 100644 index 0000000..6e07115 --- /dev/null +++ b/src/arts/lang/helpers.ts @@ -0,0 +1,95 @@ +import type { LangBranch, LangNode, LangParams, LangRecord, LangString, SupportedLocale } from './types.ts'; +import { ID_FALLBACK_SEPARATOR, ID_PREFIX } from './consts.ts'; +import { isLangString } from './guards.ts'; + +export function resolvePath(obj: LangNode | undefined, path: string): LangNode | undefined { + if (!path || obj == null) return undefined; + let current: unknown = obj; + for (const key of path.split('.')) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[key]; + } + return current as LangNode | undefined; +} + +export function parseLangRef(value: string): { path: string; fallback?: string } | null { + if (!value.startsWith(ID_PREFIX) || value.length <= ID_PREFIX.length) return null; + + const raw = value.slice(ID_PREFIX.length); + const separatorIndex = raw.indexOf(ID_FALLBACK_SEPARATOR); + + if (separatorIndex === -1) { + return { path: raw }; + } + + return { + path: raw.slice(0, separatorIndex), + fallback: raw.slice(separatorIndex + 1) + }; +} + +export function parsePathFallback(path: string): { path: string; fallback?: string } { + const separatorIndex = path.indexOf(ID_FALLBACK_SEPARATOR); + + if (separatorIndex === -1) { + return { path }; + } + + return { + path: path.slice(0, separatorIndex), + fallback: path.slice(separatorIndex + 1) + }; +} + +export function makeLangRecord(text: string, locale: SupportedLocale): LangRecord { + return { [locale]: text } as LangRecord; +} + +export function asLangString(value: unknown): LangString { + if (value == null) return ''; + if (isLangString(value)) return value; + return String(value); +} + +export function interpolateTemplate(text: string, params?: LangParams): string { + if (!params) return text; + let result = text; + for (const [key, val] of Object.entries(params)) { + result = result.replaceAll(`{{${key}}}`, String(val)); + } + return result; +} + +export function resolveRecordFallback( + record: Partial>, + chain: SupportedLocale[] +): string | undefined { + for (const locale of chain) { + const val = record[locale]; + if (val !== undefined) return val; + } + return Object.values(record).find((v) => typeof v === 'string'); +} + +export function deepMerge(target: T, source: S): T & S { + const result: LangBranch = { ...target }; + for (const key of Object.keys(source)) { + const srcVal = source[key]; + const tgtVal = result[key]; + if (isPlainBranch(srcVal) && isPlainBranch(tgtVal)) { + result[key] = deepMerge(tgtVal, srcVal); + } else { + result[key] = srcVal; + } + } + return result as T & S; +} + +function isPlainBranch(value: unknown): value is LangBranch { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof value !== 'function' + ); +} diff --git a/src/arts/lang/index.ts b/src/arts/lang/index.ts new file mode 100644 index 0000000..b7f346d --- /dev/null +++ b/src/arts/lang/index.ts @@ -0,0 +1,9 @@ +export * from './consts.ts'; +export * from './guards.ts'; +export * from './types.ts'; +export * from './plural_rules.ts'; +export * from './plural.ts'; +export * from './helpers.ts'; +export * from './engine-lang.ts'; +export * from './json.ts'; +export * from './errors.ts'; diff --git a/src/arts/lang/json.ts b/src/arts/lang/json.ts new file mode 100644 index 0000000..c7162d2 --- /dev/null +++ b/src/arts/lang/json.ts @@ -0,0 +1,71 @@ +import type { LangBranch, LangFn, LangNode, LangParams, PluralConfig } from './types.ts'; +import { p, getPluralConfig } from './plural.ts'; + +export type LangJSONValue = + | string + | number + | boolean + | null + | LangJSONObject + | LangJSONValue[]; + +export interface LangJSONObject { + [key: string]: LangJSONValue; +} + +type PluralJSON = LangJSONObject & { + __type: 'plural'; + config: LangJSONObject; +}; + +function isPluralJSON(value: LangJSONObject): value is PluralJSON { + return value.__type === 'plural' && typeof value.config === 'object' && value.config !== null; +} + +export function langNodeToJSON(node: LangNode): LangJSONValue { + if (typeof node === 'string') return node; + + if (typeof node === 'function') { + const config = getPluralConfig(node); + if (config) { + return { + __type: 'plural', + config: config as unknown as LangJSONObject + }; + } + + try { + const proxyParams = new Proxy({} as LangParams, { + get: (_, prop) => `{{${String(prop)}}}` + }); + return langNodeToJSON((node as LangFn)(proxyParams)); + } catch { + return {}; + } + } + + const result: LangJSONObject = {}; + const source = node as LangBranch; + for (const key in source) { + result[key] = langNodeToJSON(source[key]); + } + return result; +} + +export function JSONToLangNode(json: LangJSONValue): LangNode { + if (typeof json === 'string') return json as LangNode; + + if (json === null || typeof json !== 'object' || Array.isArray(json)) { + throw new TypeError('[lang] JSONToLangNode: invalid JSON shape for LangNode'); + } + + if (isPluralJSON(json)) { + return p(json.config as unknown as PluralConfig); + } + + const result: LangBranch = {}; + for (const key in json) { + result[key] = JSONToLangNode(json[key]); + } + return result; +} diff --git a/src/arts/lang/plural.ts b/src/arts/lang/plural.ts new file mode 100644 index 0000000..4b81b38 --- /dev/null +++ b/src/arts/lang/plural.ts @@ -0,0 +1,37 @@ +import type { LangParams, LangPluralFn, LangRecord, PluralConfig, PluralForms } from './types.ts'; +import type { PluralCategory } from './plural_rules.ts'; +import { pluralRule } from './plural_rules.ts'; + +type AnyLangFn = (params: LangParams) => LangRecord; + +const pluralConfigs = new WeakMap(); + +export const p =

(config: PluralConfig): LangPluralFn

=> { + const cache = new Map(); + + const fn: LangPluralFn

= (params) => { + const key = String(params.count); + const cached = cache.get(key); + if (cached) return cached; + + const result: Partial = {}; + for (const [locale, forms] of Object.entries(config)) { + if (!forms) continue; + const rule: PluralCategory = pluralRule(locale, params.count); + const typedForms = forms as PluralForms; + result[locale as keyof LangRecord] = typedForms[rule] || typedForms.other; + } + + const record = result as LangRecord; + cache.set(key, record); + return record; + }; + + pluralConfigs.set(fn as unknown as AnyLangFn, config); + return fn; +}; + +export function getPluralConfig(fn: AnyLangFn | LangPluralFn | unknown): PluralConfig | undefined { + if (typeof fn !== 'function') return undefined; + return pluralConfigs.get(fn as AnyLangFn); +} diff --git a/src/arts/lang/plural_rules.ts b/src/arts/lang/plural_rules.ts new file mode 100644 index 0000000..e2dfbb5 --- /dev/null +++ b/src/arts/lang/plural_rules.ts @@ -0,0 +1,12 @@ +export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; + +export function pluralRule(locale: string, n: number): PluralCategory { + if (typeof Intl !== 'undefined' && typeof Intl.PluralRules === 'function') { + try { + return new Intl.PluralRules(locale).select(n) as PluralCategory; + } catch { + return 'other'; + } + } + return 'other'; +} diff --git a/src/arts/lang/test/lang.test.ts b/src/arts/lang/test/lang.test.ts new file mode 100644 index 0000000..80264c0 --- /dev/null +++ b/src/arts/lang/test/lang.test.ts @@ -0,0 +1,700 @@ +/** + * lang — test suite + * + * Covers: + * - guards (isLangRef, isLangRecord, isLangString) + * - helpers (resolvePath, parseLangRef, parsePathFallback, makeLangRecord, + * interpolateTemplate, resolveRecordFallback, deepMerge) + * - plural_rules (pluralRule via Intl.PluralRules) + * - plural (p, getPluralConfig) + * - engine (createEngineLang: t, ts, fallback chain, locale param, + * extend, register, setLogger) + * - json (langNodeToJSON, JSONToLangNode, round-trip) + */ + +import { describe, it, expect, vi } from 'vitest'; +import { isLangRef, isLangRecord, isLangString } from '../guards.ts'; +import { + parseLangRef, + parsePathFallback, + resolvePath, + makeLangRecord, + interpolateTemplate, + resolveRecordFallback, + deepMerge +} from '../helpers.ts'; +import { pluralRule } from '../plural_rules.ts'; +import { p, getPluralConfig } from '../plural.ts'; +import { createEngineLang } from '../engine-lang.ts'; +import { langNodeToJSON, JSONToLangNode } from '../json.ts'; +import type { LangNode, LangBranch } from '../types.ts'; + +// ============================================================================ +// GUARDS +// ============================================================================ + +describe('isLangRef', () => { + it('returns true for a valid reference', () => { + expect(isLangRef('#?common.ok')).toBe(true); + }); + + it('returns false for prefix only without path', () => { + expect(isLangRef('#?')).toBe(false); + }); + + it('returns false for normal strings', () => { + expect(isLangRef('hola')).toBe(false); + expect(isLangRef('')).toBe(false); + }); + + it('returns false for non-strings', () => { + expect(isLangRef(null)).toBe(false); + expect(isLangRef(42)).toBe(false); + expect(isLangRef({})).toBe(false); + expect(isLangRef(undefined)).toBe(false); + }); + + it('returns true for references with nested paths', () => { + expect(isLangRef('#?a.b.c')).toBe(true); + }); +}); + +describe('isLangRecord', () => { + it('returns true for a record with es and en', () => { + expect(isLangRecord({ es: 'Aceptar', en: 'OK' })).toBe(true); + }); + + it('returns true with only en — no hardcoded locale required', () => { + expect(isLangRecord({ en: 'Hello' })).toBe(true); + }); + + it('returns false for empty objects', () => { + expect(isLangRecord({})).toBe(false); + }); + + it('returns false if values are not strings', () => { + expect(isLangRecord({ es: 42 })).toBe(false); + expect(isLangRecord({ es: null })).toBe(false); + }); + + it('returns false for arrays', () => { + expect(isLangRecord(['es', 'Hola'])).toBe(false); + }); + + it('returns false for functions', () => { + expect(isLangRecord(() => ({ es: 'x' }))).toBe(false); + }); + + it('returns false for primitives', () => { + expect(isLangRecord(null)).toBe(false); + expect(isLangRecord('hola')).toBe(false); + expect(isLangRecord(42)).toBe(false); + }); +}); + +describe('isLangString', () => { + it('accepts plain strings', () => { + expect(isLangString('hola')).toBe(true); + expect(isLangString('')).toBe(true); + }); + + it('accepts LangRecord', () => { + expect(isLangString({ en: 'Hello' })).toBe(true); + }); + + it('accepts LangRef', () => { + expect(isLangString('#?common.ok')).toBe(true); + }); + + it('rejects null and undefined', () => { + expect(isLangString(null)).toBe(false); + expect(isLangString(undefined)).toBe(false); + }); + + it('rejects numbers', () => { + expect(isLangString(42)).toBe(false); + }); +}); + +// ============================================================================ +// HELPERS +// ============================================================================ + +describe('parseLangRef', () => { + it('extracts path without fallback', () => { + expect(parseLangRef('#?common.ok')).toEqual({ path: 'common.ok' }); + }); + + it('extracts path and fallback literal', () => { + expect(parseLangRef('#?terra.calendar.month|month')).toEqual({ + path: 'terra.calendar.month', + fallback: 'month' + }); + }); + + it('returns null for non-ref strings', () => { + expect(parseLangRef('terra.calendar.month')).toBeNull(); + expect(parseLangRef('#?')).toBeNull(); + }); +}); + +describe('parsePathFallback', () => { + it('extracts path without fallback', () => { + expect(parsePathFallback('common.ok')).toEqual({ path: 'common.ok' }); + }); + + it('extracts path and fallback', () => { + expect(parsePathFallback('home.options|Options')).toEqual({ + path: 'home.options', + fallback: 'Options' + }); + }); + + it('returns full string as path when no separator', () => { + expect(parsePathFallback('noseparator')).toEqual({ path: 'noseparator' }); + }); + + it('handles empty fallback', () => { + expect(parsePathFallback('path|')).toEqual({ path: 'path', fallback: '' }); + }); +}); + +describe('resolvePath', () => { + const obj: LangBranch = { + a: { b: { c: { en: 'value' } } } + }; + + it('resolves nested paths', () => { + expect(resolvePath(obj, 'a.b.c')).toEqual({ en: 'value' }); + }); + + it('resolves one level', () => { + expect(resolvePath(obj, 'a')).toEqual({ b: { c: { en: 'value' } } }); + }); + + it('returns undefined for non-existent paths', () => { + expect(resolvePath(obj, 'a.x.y')).toBeUndefined(); + }); + + it('returns undefined for empty path', () => { + expect(resolvePath(obj, '')).toBeUndefined(); + }); +}); + +describe('makeLangRecord', () => { + it('creates a record with the specified locale', () => { + const record = makeLangRecord('Hola', 'es'); + expect(record.es).toBe('Hola'); + }); + + it('creates a record with en locale', () => { + const record = makeLangRecord('Hello', 'en'); + expect(record.en).toBe('Hello'); + expect((record as Record).es).toBeUndefined(); + }); +}); + +describe('interpolateTemplate', () => { + it('replaces {{key}} with value', () => { + expect(interpolateTemplate('Hello {{name}}', { name: 'World' })).toBe('Hello World'); + }); + + it('replaces multiple keys', () => { + expect( + interpolateTemplate('{{greeting}} {{name}}', { greeting: 'Hi', name: 'Ana' }) + ).toBe('Hi Ana'); + }); + + it('returns text unchanged if no params', () => { + expect(interpolateTemplate('Hello')).toBe('Hello'); + }); + + it('handles special regex chars in values', () => { + expect(interpolateTemplate('Code: {{code}}', { code: 'A+B' })).toBe('Code: A+B'); + }); +}); + +describe('resolveRecordFallback', () => { + it('returns value for first locale in chain', () => { + expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['en', 'es'])).toBe('Hello'); + }); + + it('falls back through the chain', () => { + expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['fr', 'en', 'es'])).toBe('Hello'); + }); + + it('uses the last available entry', () => { + expect(resolveRecordFallback({ es: 'Hola', en: 'Hello' }, ['fr', 'de', 'es'])).toBe('Hola'); + }); + + it('returns undefined for empty record', () => { + expect(resolveRecordFallback({}, ['es'])).toBeUndefined(); + }); + + it('uses Object.values fallback when chain misses', () => { + expect(resolveRecordFallback({ fr: 'Bonjour' }, ['de'])).toBe('Bonjour'); + }); +}); + +describe('deepMerge', () => { + it('merges flat branches', () => { + expect(deepMerge({ a: { en: '1' } }, { b: { en: '2' } })).toEqual({ + a: { en: '1' }, + b: { en: '2' } + }); + }); + + it('deep merges nested branches', () => { + expect( + deepMerge({ a: { x: { en: '1' }, y: { en: '2' } } }, { a: { y: { en: '3' }, z: { en: '4' } } }) + ).toEqual({ + a: { x: { en: '1' }, y: { en: '3' }, z: { en: '4' } } + }); + }); + + it('overwrites leaves with source', () => { + expect(deepMerge({ a: { en: '1' } }, { a: { en: '2' } })).toEqual({ + a: { en: '2' } + }); + }); +}); + +// ============================================================================ +// PLURAL RULES (Intl.PluralRules) +// ============================================================================ + +describe('pluralRule', () => { + describe('Spanish (es) — one/other', () => { + it('1 → one', () => expect(pluralRule('es', 1)).toBe('one')); + it('0 → other', () => expect(pluralRule('es', 0)).toBe('other')); + it('2 → other', () => expect(pluralRule('es', 2)).toBe('other')); + }); + + describe('English (en) — one/other', () => { + it('1 → one', () => expect(pluralRule('en', 1)).toBe('one')); + it('2 → other', () => expect(pluralRule('en', 2)).toBe('other')); + }); + + describe('Arabic (ar) — 6 forms', () => { + it('0 → zero', () => expect(pluralRule('ar', 0)).toBe('zero')); + it('1 → one', () => expect(pluralRule('ar', 1)).toBe('one')); + it('2 → two', () => expect(pluralRule('ar', 2)).toBe('two')); + it('5 → few', () => expect(pluralRule('ar', 5)).toBe('few')); + it('15 → many', () => expect(pluralRule('ar', 15)).toBe('many')); + it('100 → other', () => expect(pluralRule('ar', 100)).toBe('other')); + }); + + describe('region-normalized locales', () => { + it('es-ES → one for 1', () => expect(pluralRule('es-ES', 1)).toBe('one')); + it('en-US → one for 1', () => expect(pluralRule('en-US', 1)).toBe('one')); + }); + + describe('unknown locale — falls back to Intl root', () => { + it('xx, 5 → other', () => expect(pluralRule('xx', 5)).toBe('other')); + }); +}); + +// ============================================================================ +// PLURAL — p() with WeakMap +// ============================================================================ + +describe('p()', () => { + const plural = p({ + es: { one: '{{count}} elemento', other: '{{count}} elementos' }, + en: { one: '{{count}} item', other: '{{count}} items' } + }); + + it('selects singular form for 1', () => { + const record = plural({ count: 1 }); + expect(record.es).toBe('{{count}} elemento'); + }); + + it('selects plural form for 2', () => { + const record = plural({ count: 2 }); + expect(record.es).toBe('{{count}} elementos'); + }); + + it('caches by count', () => { + const r1 = plural({ count: 5 }); + const r2 = plural({ count: 5 }); + expect(r1).toBe(r2); + }); + + it('uses other as fallback when form missing', () => { + const fallback = p({ es: { other: 'varios' } }); + const record = fallback({ count: 1 }); + expect(record.es).toBe('varios'); + }); + + it('getPluralConfig retrieves config via WeakMap', () => { + expect(getPluralConfig(plural)).toBeDefined(); + expect(getPluralConfig(plural)!.es).toBeDefined(); + }); + + it('getPluralConfig returns undefined for non-plural functions', () => { + const regular = () => ({ es: 'x' }); + expect(getPluralConfig(regular)).toBeUndefined(); + }); + + it('stores config via WeakMap, not on the function itself', () => { + expect((plural as unknown as Record).__pluralConfig).toBeUndefined(); + }); +}); + +// ============================================================================ +// ENGINE — createEngineLang +// ============================================================================ + +const schema = { + common: { + ok: { es: 'Aceptar', en: 'OK' }, + cancel: { es: 'Cancelar', en: 'Cancel' } + }, + greet: (params: { name: string }) => ({ + es: `Hola, ${params.name}`, + en: `Hello, ${params.name}` + }), + messages: { + unread: p({ + es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' }, + en: { one: '{{count}} unread message', other: '{{count}} unread messages' } + }) + }, + ref: '#?common.ok', + nested: { + deep: { + value: { es: 'Profundo', en: 'Deep' } + } + }, + onlyEn: { en: 'English only' }, + onlyFr: { fr: 'Francais seulement' } +} as const satisfies LangNode; + +describe('createEngineLang — t()', () => { + it('translates a simple key to the requested locale', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.t('common.ok', undefined, 'es')).toBe('Aceptar'); + }); + + it('translates to alternative locale', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.t('common.ok', undefined, 'en')).toBe('OK'); + }); + + it('returns path when key does not exist', () => { + const lang = createEngineLang(schema, 'es'); + expect((lang.t as (p: string, params: undefined, locale: 'es') => string)('no.existe', undefined, 'es')).toBe( + 'no.existe' + ); + }); + + it('interpolates params', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.t('greet', { name: 'Ana' }, 'es')).toBe('Hola, Ana'); + }); + + it('resolves LangRef references (#?)', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.t('ref', undefined, 'es')).toBe('Aceptar'); + }); + + it('resolves deep nested paths', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.t('nested.deep.value', undefined, 'es')).toBe('Profundo'); + }); + + it('resolves plural — one', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.t('messages.unread', { count: 1 }, 'es')).toBe('1 mensaje sin leer'); + }); + + it('resolves plural — other', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.t('messages.unread', { count: 5 }, 'es')).toBe('5 mensajes sin leer'); + }); + + it('returns fallback literal when key missing and | present', () => { + const lang = createEngineLang(schema, 'es'); + expect( + (lang.t as (p: string, params: undefined, locale: 'es') => string)( + 'missing.key|Alternate', + undefined, + 'es' + ) + ).toBe('Alternate'); + }); + + it('interpolates params in the fallback literal', () => { + const lang = createEngineLang(schema, 'es'); + expect( + (lang.t as (p: string, params: Record, locale: 'es') => string)( + 'missing.greet|Hola {{name}}', + { name: 'Ana' }, + 'es' + ) + ).toBe('Hola Ana'); + }); +}); + +describe('createEngineLang — ts()', () => { + it('translates a LangRecord directly', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.ts({ es: 'Directo', en: 'Direct' }, 'es')).toBe('Directo'); + }); + + it('translates a LangRecord in explicit locale', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.ts({ es: 'Directo', en: 'Direct' }, 'en')).toBe('Direct'); + }); + + it('returns empty string for null', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.ts(null as unknown as string, 'es')).toBe(''); + }); + + it('resolves LangRef references', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.ts('#?common.ok', 'es')).toBe('Aceptar'); + }); + + it('uses fallback literal when ref key does not exist', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.ts('#?terra.calendar.month|month', 'es')).toBe('month'); + }); + + it('keeps plain strings as literals', () => { + const lang = createEngineLang(schema, 'es'); + expect(lang.ts('plain text', 'es')).toBe('plain text'); + }); +}); + +describe('createEngineLang — fallback chain', () => { + it('falls back through the chain: current → chain → default', () => { + const lang = createEngineLang(schema, 'es', ['en']); + expect(lang.t('common.ok', undefined, 'fr')).toBe('OK'); + }); + + it('falls back to default when chain misses', () => { + const lang = createEngineLang(schema, 'es', ['de']); + expect(lang.t('common.ok', undefined, 'fr')).toBe('Aceptar'); + }); + + it('skips chain locales equal to current or default', () => { + const lang = createEngineLang(schema, 'es', ['es']); + expect(lang.t('common.ok', undefined, 'en')).toBe('OK'); + }); +}); + +describe('createEngineLang — extend()', () => { + it('adds a simple namespace', () => { + const lang = createEngineLang(schema, 'es'); + lang.extend('shop', { product: { es: 'Producto', en: 'Product' } }); + expect( + (lang.t as (p: string, params: undefined, locale: 'es') => string)( + 'shop.product', + undefined, + 'es' + ) + ).toBe('Producto'); + }); + + it('supports dotted namespaces', () => { + const lang = createEngineLang(schema, 'es'); + lang.extend('app.settings', { title: { es: 'Ajustes', en: 'Settings' } }); + expect( + (lang.t as (p: string, params: undefined, locale: 'es') => string)( + 'app.settings.title', + undefined, + 'es' + ) + ).toBe('Ajustes'); + }); + + it('deep-merges without overwriting existing keys', () => { + const lang = createEngineLang(schema, 'es'); + lang.extend('common', { extra: { es: 'Extra', en: 'Extra' } }); + expect(lang.t('common.ok', undefined, 'es')).toBe('Aceptar'); + expect( + (lang.t as (p: string, params: undefined, locale: 'es') => string)( + 'common.extra', + undefined, + 'es' + ) + ).toBe('Extra'); + }); + + it('notifies schema listeners', () => { + const lang = createEngineLang(schema, 'es'); + const spy = vi.fn(); + lang.onSchemaChange(spy); + lang.extend('shop', { product: { es: 'Producto' } }); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('unsubscribe stops schema notifications', () => { + const lang = createEngineLang(schema, 'es'); + const spy = vi.fn(); + const unsub = lang.onSchemaChange(spy); + unsub(); + lang.extend('shop', { product: { es: 'Producto' } }); + expect(spy).not.toHaveBeenCalled(); + }); +}); + +describe('createEngineLang — register()', () => { + it('returns a new instance with the namespace merged', () => { + const lang = createEngineLang(schema, 'es'); + const extended = lang.register('shop', { product: { es: 'Producto', en: 'Product' } }); + expect( + (extended.t as (p: string, params: undefined, locale: 'es') => string)( + 'shop.product', + undefined, + 'es' + ) + ).toBe('Producto'); + }); + + it('leaves the original instance unaffected', () => { + const lang = createEngineLang(schema, 'es'); + lang.register('shop', { product: { es: 'Producto' } }); + expect( + (lang.t as (p: string, params: undefined, locale: 'es') => string)( + 'shop.product', + undefined, + 'es' + ) + ).toBe('shop.product'); + }); + + it('child inherits fallback chain from the parent', () => { + const lang = createEngineLang(schema, 'es', ['en']); + const child = lang.register('shop', { product: { es: 'Producto', en: 'Product' } }); + expect( + (child.t as (p: string, params: undefined, locale: 'fr') => string)( + 'shop.product', + undefined, + 'fr' + ) + ).toBe('Product'); + }); +}); + +describe('createEngineLang — setLogger()', () => { + it('replaces the default logger', () => { + const lang = createEngineLang(schema, 'es'); + const warn = vi.fn(); + const error = vi.fn(); + lang.setLogger({ warn, error }); + expect(lang.getDefaultLocale()).toBe('es'); + }); + + it('ignores subsequent setLogger calls (set-once)', () => { + const lang = createEngineLang(schema, 'es'); + const warn1 = vi.fn(); + const warn2 = vi.fn(); + lang.setLogger({ warn: warn1, error: vi.fn() }); + lang.setLogger({ warn: warn2, error: vi.fn() }); + expect(lang.getDefaultLocale()).toBe('es'); + }); +}); + +describe('createEngineLang — non-Spanish defaults', () => { + it('works with en as default locale', () => { + const enSchema = { greeting: { en: 'Hello' } } as const; + const lang = createEngineLang(enSchema, 'en'); + expect(lang.t('greeting', undefined, 'en')).toBe('Hello'); + }); + + it('works with fr as default locale', () => { + const frSchema = { greeting: { fr: 'Bonjour' } } as const; + const lang = createEngineLang(frSchema, 'fr'); + expect(lang.t('greeting', undefined, 'fr')).toBe('Bonjour'); + }); +}); + +// ============================================================================ +// JSON — langNodeToJSON / JSONToLangNode +// ============================================================================ + +describe('langNodeToJSON', () => { + it('serializes LangRecord as plain object', () => { + expect(langNodeToJSON({ en: 'Hello' })).toEqual({ en: 'Hello' }); + }); + + it('serializes namespaces recursively', () => { + const node = { common: { ok: { es: 'Aceptar', en: 'OK' } } } satisfies LangNode; + const result = langNodeToJSON(node) as { common: { ok: Record } }; + expect(result.common.ok).toEqual({ es: 'Aceptar', en: 'OK' }); + }); + + it('serializes interpolation functions with {{tokens}}', () => { + const fn = (params: { name: string }) => ({ es: `Hola ${params.name}`, en: `Hello ${params.name}` }); + const result = langNodeToJSON(fn) as { es: string; en: string }; + expect(result.es).toBe('Hola {{name}}'); + expect(result.en).toBe('Hello {{name}}'); + }); + + it('serializes plural via WeakMap as { __type: "plural", config }', () => { + const plural = p({ en: { one: '1 item', other: '{{count}} items' } }); + const result = langNodeToJSON(plural) as { __type: string; config: unknown }; + expect(result.__type).toBe('plural'); + expect(result.config).toBeDefined(); + }); +}); + +describe('JSONToLangNode', () => { + it('reconstructs LangRecord', () => { + const result = JSONToLangNode({ en: 'Hello' }); + expect(result).toEqual({ en: 'Hello' }); + }); + + it('reconstructs plurals as callable functions', () => { + const config = { en: { one: '{{count}} item', other: '{{count}} items' } }; + const restored = JSONToLangNode({ __type: 'plural', config }) as unknown as (p: { + count: number; + }) => Record; + expect(typeof restored).toBe('function'); + expect(restored({ count: 1 }).en).toBe('{{count}} item'); + }); + + it('reconstructs namespaces recursively', () => { + const json = { common: { ok: { en: 'OK' } } }; + const restored = JSONToLangNode(json) as { common: { ok: { en: string } } }; + expect(restored.common.ok.en).toBe('OK'); + }); + + it('throws on invalid JSON shape', () => { + expect(() => JSONToLangNode(42 as never)).toThrow(TypeError); + expect(() => JSONToLangNode(true as never)).toThrow(TypeError); + }); +}); + +describe('round-trip langNodeToJSON → JSONToLangNode', () => { + it('preserves LangRecord', () => { + const original: LangNode = { en: 'Hello' }; + const json = langNodeToJSON(original); + const restored = JSONToLangNode(json) as { en: string }; + expect(restored.en).toBe('Hello'); + }); + + it('preserves plurals and keeps them callable', () => { + const original = p({ en: { one: '1 item', other: '{{count}} items' } }); + const json = langNodeToJSON(original); + const restored = JSONToLangNode(json) as unknown as (p: { count: number }) => Record; + expect(typeof restored).toBe('function'); + expect(restored({ count: 1 }).en).toBe('1 item'); + expect(restored({ count: 5 }).en).toBe('{{count}} items'); + }); + + it('preserves a full tree', () => { + const node: LangNode = { + common: { ok: { es: 'Aceptar', en: 'OK' } }, + items: p({ en: { one: '1 item', other: '{{count}} items' } }) + }; + const json = langNodeToJSON(node); + const restored = JSONToLangNode(json) as LangBranch; + const common = restored.common as LangBranch; + expect((common.ok as Record).en).toBe('OK'); + expect(typeof restored.items).toBe('function'); + }); +}); diff --git a/src/arts/lang/types.ts b/src/arts/lang/types.ts new file mode 100644 index 0000000..5172e77 --- /dev/null +++ b/src/arts/lang/types.ts @@ -0,0 +1,120 @@ +import type { ID_PREFIX } from './consts.ts'; +import type { PluralCategory } from './plural_rules.ts'; + +export type SupportedLocale = 'es' | 'en' | 'ar' | 'de' | 'fr' | 'it' | 'pt' | 'ca' | 'eu' | 'gl'; + +export type LangRef = `${typeof ID_PREFIX}${string}`; + +export type LangRecord = { + [K in SupportedLocale]?: string; +}; + +export type LangParams = Record; + +// NOTE: default is `any` (not `LangParams`) to preserve contravariant +// parameter variance: a schema `(params: { name: string }) => ...` must be +// assignable to the default type without forcing the user to widen their +// signature. Public type-safety lives in `t()` / `ts()`, which infer the +// concrete `P` via `ParamsFor`. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type LangFn

= (params: P) => LangRecord; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type LangPluralFn

= (params: { count: number } & P) => LangRecord; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type LangValue

= LangRecord | LangFn

| LangPluralFn

| LangRef; + +export type LangBranch = { [key: string]: LangNode }; + +export type LangNode = LangValue | LangBranch; + +export type LangString = string | LangRecord | LangRef; + +type Prev = [never, 0, 1, 2, 3, 4, 5, 6]; + +export type LeafPaths = [D] extends [never] + ? never + : T extends LangValue + ? '' + : T extends object + ? { + [K in keyof T & string]: T[K] extends LangValue + ? K + : `${K}.${LeafPaths & string}`; + }[keyof T & string] + : never; + +export type GetTypeAtPath = 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; + +export type ParamsFor = T extends (params: infer P) => LangRecord ? P : never; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type HasParams = T extends (params: any) => LangRecord ? true : false; + +export type PluralForms = Partial> & { other: string }; + +export type PluralConfig = { + [K in SupportedLocale]?: PluralForms; +}; + +export type EngineLangInstance = { + t: { +

, TType = GetTypeAtPath>( + path: P, + params: HasParams extends true ? ParamsFor : LangParams | undefined, + locale: SupportedLocale + ): string; + (path: string, params: LangParams | undefined, locale: SupportedLocale): string; + }; + + ts: (value: LangString, locale: SupportedLocale) => string; + + onSchemaChange: (fn: () => void) => () => void; + extend: (namespace: string, module: LangNode) => void; + register: ( + namespace: NS, + module: M + ) => EngineLangInstance; + setLogger: (logger: LangLogger) => void; + getDefaultLocale: () => SupportedLocale; + getFallbackChain: () => SupportedLocale[] | undefined; +}; + +export type ActiveLangInstance = { + t: { +

, TType = GetTypeAtPath>( + path: P, + params?: HasParams extends true ? ParamsFor : LangParams, + locale?: SupportedLocale + ): string; + (path: string, params?: LangParams, locale?: SupportedLocale): string; + }; + + ts: (value: LangString, locale?: SupportedLocale) => string; + + getLocale: () => SupportedLocale; + setLocale: (locale: SupportedLocale) => void; + onLocaleChange: (fn: (locale: SupportedLocale) => void) => () => void; + onSchemaChange: (fn: () => void) => () => void; + extend: (namespace: string, module: LangNode) => void; + register: ( + namespace: NS, + module: M + ) => ActiveLangInstance; + setLogger: (logger: LangLogger) => void; + dispose: () => void; +}; + +export interface LangLogger { + warn: (category: string, message: string) => void; + error: (category: string, message: string) => void; +} diff --git a/src/arts/logr/README.md b/src/arts/logr/README.md new file mode 100644 index 0000000..f6294d7 --- /dev/null +++ b/src/arts/logr/README.md @@ -0,0 +1,445 @@ +# logr + +Professional structured logger. **Zero external dependencies.** Decoupled from +i18n. Dynamic transports with per-sink filtering. Async-aware dispatch. +Failure routing with `deniedFor`. Built-in adapters for Sentry, Datadog, +Logtail (Better Stack), Grafana Loki and OpenTelemetry. + +--- + +## Features + +- **6 severity levels** aligned with pino / Log4j / OpenTelemetry / Sentry: + `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`. +- **Structured entries** — category, context, tags, traceId, error, durationMs, + source (auto-captured from stack in DEV). +- **Lazy messages** — pass a thunk, evaluated only if the entry passes the + global level filter. +- **globalContext** merged into every entry (appVersion, env, sessionId, ...). +- **`child(ctx)`** — logger with extended context; shares history and transports. +- **`time(label)` / `timeEnd(label)`** — duration measurement with `timer` tag. +- **Dynamic transports** — `addTransport()`, `subscribe()`, `removeAllTransports()`. +- **Per-transport filters** — `minLevel` and `filter` at the transport level. +- **Hybrid dispatch** — sync loop, transports may return `Promise` for + async work. Caller never awaits. +- **Failure routing** — when a transport throws (sync) or rejects (async), a + synthetic ERROR entry is dispatched to the remaining transports with + `deniedFor: [failedName]`. Guarantees no cascade loop. +- **Built-in adapters** — `sentryTransport`, `datadogTransport`, + `logtailTransport`, `lokiTransport`, `otelTransport`. All accept the SDK as + an injected parameter so logr stays dep-free. + +--- + +## Architecture + +``` +logr/ +├── index.ts Barrel exports +├── types.ts LogLevel, LogEntry, LogInput, Transport, EngineLogger, ... +├── engine-logger.ts Factory createEngineLogger() +├── transports.ts consoleTransport, httpTransport, callbackTransport +├── source.ts captureSource(), extractError() +├── consts.ts runtime identifier (`engine_logger`) +├── adapters/ +│ ├── sentry.ts sentryTransport(sentrySDK, options) +│ ├── datadog.ts datadogTransport(ddLogger, options) +│ ├── logtail.ts logtailTransport(logtailInstance, options) +│ ├── loki.ts lokiTransport({ url, labels, ... }) +│ └── otel.ts otelTransport(otelLogger, options) +└── test/ + └── logr.test.ts Unit tests (vitest, node env) +``` + +## Alias + +Configured in `svelte.config.js`: + +```js +alias: { $logr: 'src/arts/logr' } +``` + +Imports: + +```ts +import { createEngineLogger, LogLevel, consoleTransport } from '$logr'; +import { sentryTransport } from '$logr/adapters/sentry'; +import { datadogTransport } from '$logr/adapters/datadog'; +import { logtailTransport } from '$logr/adapters/logtail'; +import { lokiTransport } from '$logr/adapters/loki'; +import { otelTransport } from '$logr/adapters/otel'; +``` + +--- + +## Quick start + +```ts +import { createEngineLogger, LogLevel, consoleTransport } from '$logr'; + +const logger = createEngineLogger({ + level: LogLevel.DEBUG, + globalContext: { appVersion: '1.2.3', env: 'prod' }, + transports: [consoleTransport()] +}); + +logger.info('auth', 'Login succeeded', { + context: { userId: 42 }, + tags: ['auth', 'security'], + traceId: 'req-abc-123' +}); + +try { ... } catch (err) { + logger.error('db', 'Query failed', { error: err, traceId: 'req-abc-123' }); +} + +// Duration measurement +logger.time('fetch-user'); +await fetchUser(); +logger.timeEnd('fetch-user', 'perf'); // emits INFO with durationMs and tag 'timer' + +// Lazy message — only evaluated if the entry passes the level filter +logger.debug('sql', () => `Query: ${expensiveFormat(query)}`); +``` + +--- + +## Log levels + +```ts +enum LogLevel { + TRACE = 0, + DEBUG = 1, + INFO = 2, + WARN = 3, + ERROR = 4, + FATAL = 5, + NONE = 999 // disables all logs +} +``` + +| Level | Intended use | +| ----- | --------------------------------------------------------------------- | +| TRACE | Very verbose tracing (spans, frame-by-frame) | +| DEBUG | Development diagnostics | +| INFO | Normal flow events worth noting | +| WARN | Unexpected but non-interrupting situations | +| ERROR | Recoverable errors that need attention | +| FATAL | Unrecoverable failures (route to immediate paging) | + +--- + +## API + +### `createEngineLogger(options)` + +```ts +interface LoggerOptions { + level?: LogLevel; // @default LogLevel.WARN + maxLogs?: number; // @default 1000 + transports?: Transport[]; // @default [consoleTransport()] + globalContext?: Record; + captureSource?: boolean; // @default true in DEV, false in PROD +} +``` + +### Level methods + +```ts +logger.trace(category, message, input?) +logger.debug(category, message, input?) +logger.info (category, message, input?) +logger.warn (category, message, input?) +logger.error(category, message, input?) +logger.fatal(category, message, input?) +``` + +Where: + +```ts +type LogMessage = string | (() => string); // lazy thunks supported + +interface LogInput { + context?: Record; // merged with globalContext + error?: unknown; // extracted to LogError { name, message, stack, cause } + tags?: string[]; // finer-grained filtering than category + traceId?: string; // correlate across async flows + durationMs?: number; // auto-set by timeEnd() + source?: LogSource; // manual override of auto-captured file:line +} +``` + +### History + +```ts +logger.getLogs(filters?) // defensive copy, filtered by level/minLevel/category/tag/traceId/since +logger.clear() +logger.serialize() // JSON string (circular-safe) +``` + +### Runtime controls + +```ts +logger.setLevel(LogLevel.WARN) +logger.setMaxLogs(500) +logger.setGlobalContext({ appVersion: '1.3.0' }) +``` + +### Dynamic transports + +```ts +const remove = logger.addTransport(transport); // returns a detach fn +const unsub = logger.subscribe((entry) => ...); // sugar over addTransport +logger.removeAllTransports(); +logger.transports(); // read-only snapshot +``` + +### Child loggers + +```ts +const reqLog = logger.child({ requestId: 'req-1', userId: 42 }); +reqLog.info('auth', 'authenticated'); // context merges parent + child +``` + +Children **share** history, transports and level with the parent. They only +extend `globalContext`. + +### Timers + +```ts +logger.time('fetch'); +await doWork(); +logger.timeEnd('fetch', 'perf', 'operation done'); // emits INFO with durationMs +``` + +--- + +## Transport interface + +```ts +interface Transport { + write(entry: LogEntry): void | Promise; + minLevel?: LogLevel; + filter?(entry: LogEntry): boolean; + name?: string; +} +``` + +- `minLevel` and `filter` are applied **after** the logger's global level + filter, for fine-grained per-sink routing. +- `name` appears in introspection and in `deniedFor` on failure entries. +- Sync throws and promise rejections both route to the failure handler. + +--- + +## Failure routing (`deniedFor`) + +If `transport.write()` throws or rejects: + +1. A synthetic `ERROR` entry is created with: + - `category: 'engine_logger'` + - `message: 'Transport "X" failed: ...'` + - `error`: structured `LogError` from the thrown value + - `tags: ['engine_logger', 'transport-failure']` + - `deniedFor: ['X']` +2. The failure entry is dispatched to every **other** transport, skipping the + failed one by reference. +3. If a second transport throws while handling the failure entry, the + secondary failure is **swallowed** (console-only) — this guard guarantees + no cascade loop. + +This is why you can attach Sentry (or any other transport) safely: if Sentry +is down, the failure gets logged to console/Datadog/Loki/etc., but never +back to Sentry. + +--- + +## Built-in transports + +### `consoleTransport(options?)` + +```ts +interface ConsoleTransportOptions { + timestamp?: boolean; // @default true + prefix?: boolean; // @default true + source?: boolean; // @default true — shows "(file.ts:42)" + minLevel?: LogLevel; +} +``` + +Browser/Node console with the appropriate method per level (TRACE → debug, +FATAL → error). + +### `httpTransport({ url, headers?, minLevel? })` + +Fire-and-forget POST to an arbitrary endpoint. No retry/batching built in — +use it for prototypes or wrap it for production. + +### `callbackTransport(fn, options?)` + +Factory that builds a `Transport` out of a callback. Structurally equivalent +to `logger.subscribe(fn)` but lets you pre-register it in +`LoggerOptions.transports` and configure `minLevel`, `filter`, `name`. + +--- + +## Adapters + +All adapters follow the same pattern: **receive the third-party SDK as an +injected parameter**. logr itself never imports `@sentry/*`, `@datadog/*`, +etc. — callers install those packages and pass the already-initialized SDK. +This keeps logr zero-dep and makes tests trivial (mock the SDK with a plain +object). + +### Sentry — `sentryTransport(SentrySDK, options?)` + +```ts +import * as Sentry from '@sentry/browser'; +import { sentryTransport } from '$logr/adapters/sentry'; + +Sentry.init({ dsn: '', environment: 'prod' }); + +logger.addTransport(sentryTransport(Sentry, { + minLevel: LogLevel.ERROR, // @default ERROR + breadcrumbLevel: LogLevel.INFO // @default INFO +})); +``` + +- `ERROR+` → `captureException(entry.error)` if present, otherwise + `captureMessage(entry.message, severity)`. +- `[breadcrumbLevel, minLevel)` → `addBreadcrumb(...)` attached to the next + captured event. +- `< breadcrumbLevel` → ignored. +- Every event passes through `Sentry.withScope()` to attach tags + (`category`, `traceId`, `tag:x`), contexts (`log.context`, `log.source`, + `log.timing`) without polluting the global scope. + +### Datadog — `datadogTransport(ddLogger, options?)` + +```ts +import { datadogLogs } from '@datadog/browser-logs'; +import { datadogTransport } from '$logr/adapters/datadog'; + +datadogLogs.init({ + clientToken: 'pubXXXXXXXXXX', + site: 'datadoghq.eu', + service: 'my-app', + env: 'prod', + version: '1.0.0' +}); + +logger.addTransport(datadogTransport(datadogLogs.logger, { minLevel: LogLevel.WARN })); +``` + +Level mapping: TRACE/DEBUG → `debug`, INFO → `info`, WARN → `warn`, +ERROR/FATAL → `error`. Original level preserved in `messageContext.logLevel`. +Uses reserved-safe names (`logTags`, `sourceLocation`, `durationMs`) to avoid +Datadog's reserved attributes (`source`, `duration`, `tags`). + +### Logtail (Better Stack) — `logtailTransport(logtailInstance, options?)` + +```ts +import { Logtail } from '@logtail/browser'; +import { logtailTransport } from '$logr/adapters/logtail'; + +const lt = new Logtail(''); +logger.addTransport(logtailTransport(lt, { minLevel: LogLevel.INFO })); +``` + +### Grafana Loki — `lokiTransport(options)` + +No SDK required — pure HTTP POST to the Loki push endpoint. + +```ts +import { lokiTransport } from '$logr/adapters/loki'; + +logger.addTransport(lokiTransport({ + url: 'https://logs-prod-006.grafana.net/loki/api/v1/push', + labels: { app: 'my-app', env: 'prod' }, // low-cardinality only! + basicAuthUser: '123456', + basicAuthPassword: 'eyJhbG...', + tenantId: 'my-tenant' // X-Scope-OrgID header +})); +``` + +The **level** goes into the stream labels (low cardinality, safe). Everything +else (context, tags, traceId, error) is JSON-encoded in the log line so you +can query with LogQL: +`{app="my-app"} | json | traceId="req-1"`. + +### OpenTelemetry — `otelTransport(otelLogger, options?)` + +```ts +import { logs } from '@opentelemetry/api-logs'; +import { LoggerProvider } from '@opentelemetry/sdk-logs'; +import { otelTransport } from '$logr/adapters/otel'; + +const provider = new LoggerProvider({ /* ... */ }); +logs.setGlobalLoggerProvider(provider); +const otelLogger = logs.getLogger('my-app'); + +logger.addTransport(otelTransport(otelLogger)); +``` + +Maps `LogLevel` to OTel severityNumber: TRACE=1, DEBUG=5, INFO=9, WARN=13, +ERROR=17, FATAL=21 (base of each 4-number band per the OTel spec). +Structured context, tags, source and error flatten into OTel attributes +using dotted keys (`log.context.userId`, `code.filepath`, `exception.type`, +etc.). + +--- + +## Patterns + +### Sentry + breadcrumbs + metrics + +```ts +// Events in Sentry (ERROR+) and breadcrumbs (INFO/WARN) +logger.addTransport(sentryTransport(Sentry)); + +// Counter of errors +logger.subscribe((entry) => { + if (entry.level >= LogLevel.ERROR) metrics.errorsPerMinute.inc(); +}); +``` + +### Scoped child for a request + +```ts +export async function handle(request: Request) { + const reqLog = logger.child({ + requestId: crypto.randomUUID(), + path: new URL(request.url).pathname + }); + reqLog.info('http', 'incoming request'); + try { + return await handler(request, reqLog); + } catch (err) { + reqLog.error('http', 'handler threw', { error: err }); + throw err; + } +} +``` + +### Feature-flagged transport + +```ts +if (flags.enableRemoteLogs) { + const detach = logger.addTransport(httpTransport({ url: '...' })); + onFlagDisable(() => detach()); +} +``` + +--- + +## Tests + +```bash +npx vitest run --project server +``` + +Covers 171 cases: level filtering, lazy messages, globalContext merging, +error extraction, tags/traceId, time/timeEnd, child semantics, getLogs +filters, dynamic add/remove, per-transport minLevel/filter, sync and async +failure routing with `deniedFor`, cascade guard, serialize, plus adapter +unit tests (Sentry, Datadog, Logtail, Loki, OpenTelemetry) using mocked SDKs. diff --git a/src/arts/logr/adapters/adapters.test.ts b/src/arts/logr/adapters/adapters.test.ts new file mode 100644 index 0000000..7662172 --- /dev/null +++ b/src/arts/logr/adapters/adapters.test.ts @@ -0,0 +1,444 @@ +/** + * Adapter test suite — sentry, datadog, logtail, loki, otel. + * + * All adapters use duck-typed SDK injection so the tests mock the SDK with + * plain objects/spies. No real network calls are made. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { LogLevel, createEngineLogger, type LogEntry } from '../index.ts'; +import { sentryTransport, type SentryLike, type SentryScopeLike } from './sentry.ts'; +import { datadogTransport, type DatadogLoggerLike } from './datadog.ts'; +import { logtailTransport, type LogtailLike } from './logtail.ts'; +import { lokiTransport } from './loki.ts'; +import { otelTransport, type OtelLoggerLike, type OtelLogRecord } from './otel.ts'; + +// ============================================================================ +// SENTRY +// ============================================================================ + +function mockSentry(): { + sentry: SentryLike; + calls: { + captureException: unknown[]; + captureMessage: { msg: string; level?: string }[]; + breadcrumbs: { message?: string; level?: string; category?: string }[]; + tags: Record; + contexts: Record | null>; + }; +} { + const calls = { + captureException: [] as unknown[], + captureMessage: [] as { msg: string; level?: string }[], + breadcrumbs: [] as { message?: string; level?: string; category?: string }[], + tags: {} as Record, + contexts: {} as Record | null> + }; + + const scope: SentryScopeLike = { + setTag(key, value) { + calls.tags[key] = value; + }, + setContext(name, context) { + calls.contexts[name] = context; + } + }; + + const sentry: SentryLike = { + captureException(err) { + calls.captureException.push(err); + }, + captureMessage(msg, level) { + calls.captureMessage.push({ msg, level }); + }, + addBreadcrumb(bc) { + calls.breadcrumbs.push(bc); + }, + withScope(cb) { + cb(scope); + } + }; + + return { sentry, calls }; +} + +describe('sentryTransport', () => { + it('routes INFO to breadcrumb', () => { + const { sentry, calls } = mockSentry(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(sentryTransport(sentry)); + log.info('auth', 'Login attempt'); + + expect(calls.breadcrumbs).toHaveLength(1); + expect(calls.breadcrumbs[0].message).toBe('Login attempt'); + expect(calls.breadcrumbs[0].level).toBe('info'); + expect(calls.captureMessage).toHaveLength(0); + }); + + it('routes ERROR without error to captureMessage', () => { + const { sentry, calls } = mockSentry(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(sentryTransport(sentry)); + log.error('auth', 'Login failed'); + + expect(calls.captureMessage).toHaveLength(1); + expect(calls.captureMessage[0]).toEqual({ msg: 'Login failed', level: 'error' }); + }); + + it('routes ERROR with error to captureException', () => { + const { sentry, calls } = mockSentry(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(sentryTransport(sentry)); + const err = new Error('DB down'); + log.error('db', 'Query failed', { error: err }); + + expect(calls.captureException).toHaveLength(1); + const captured = calls.captureException[0] as Error; + expect(captured.message).toBe('DB down'); + expect(captured.name).toBe('Error'); + }); + + it('routes FATAL to captureMessage with "fatal" severity', () => { + const { sentry, calls } = mockSentry(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(sentryTransport(sentry)); + log.fatal('kernel', 'Unrecoverable'); + expect(calls.captureMessage[0].level).toBe('fatal'); + }); + + it('attaches category/traceId/tags as scope tags', () => { + const { sentry, calls } = mockSentry(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(sentryTransport(sentry)); + log.error('auth', 'fail', { traceId: 'req-1', tags: ['login', 'oauth'] }); + + expect(calls.tags.category).toBe('auth'); + expect(calls.tags.traceId).toBe('req-1'); + expect(calls.tags['tag:login']).toBe('true'); + expect(calls.tags['tag:oauth']).toBe('true'); + }); + + it('attaches context and source as scope contexts', () => { + const { sentry, calls } = mockSentry(); + const log = createEngineLogger({ + level: LogLevel.TRACE, + globalContext: { appVersion: '1.0' }, + transports: [] + }); + log.addTransport(sentryTransport(sentry)); + log.error('t', 'fail', { context: { userId: 42 } }); + + expect(calls.contexts['log.context']).toEqual({ appVersion: '1.0', userId: 42 }); + }); + + it('does not emit below breadcrumbLevel', () => { + const { sentry, calls } = mockSentry(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(sentryTransport(sentry, { breadcrumbLevel: LogLevel.INFO })); + log.debug('t', 'skipped'); + log.trace('t', 'skipped'); + expect(calls.breadcrumbs).toHaveLength(0); + expect(calls.captureMessage).toHaveLength(0); + }); +}); + +// ============================================================================ +// DATADOG +// ============================================================================ + +function mockDatadog(): { + dd: DatadogLoggerLike; + calls: { level: string; message: string; context: unknown; error?: Error }[]; +} { + const calls: { level: string; message: string; context: unknown; error?: Error }[] = []; + const dd: DatadogLoggerLike = { + debug(msg, ctx) { + calls.push({ level: 'debug', message: msg, context: ctx }); + }, + info(msg, ctx) { + calls.push({ level: 'info', message: msg, context: ctx }); + }, + warn(msg, ctx) { + calls.push({ level: 'warn', message: msg, context: ctx }); + }, + error(msg, ctx, err) { + calls.push({ level: 'error', message: msg, context: ctx, error: err }); + } + }; + return { dd, calls }; +} + +describe('datadogTransport', () => { + it('maps TRACE/DEBUG to debug, FATAL/ERROR to error', () => { + const { dd, calls } = mockDatadog(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE })); + + log.trace('t', 'a'); + log.debug('t', 'b'); + log.info('t', 'c'); + log.warn('t', 'd'); + log.error('t', 'e'); + log.fatal('t', 'f'); + + expect(calls.map((c) => c.level)).toEqual(['debug', 'debug', 'info', 'warn', 'error', 'error']); + }); + + it('preserves original level in messageContext.logLevel', () => { + const { dd, calls } = mockDatadog(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE })); + log.fatal('t', 'boom'); + const ctx = calls[0].context as Record; + expect(ctx.logLevel).toBe('FATAL'); + }); + + it('uses reserved-safe names (logTags, sourceLocation, durationMs)', () => { + const { dd, calls } = mockDatadog(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE })); + log.info('t', 'hi', { + tags: ['x'], + traceId: 'r1', + durationMs: 42 + }); + + const ctx = calls[0].context as Record; + expect(ctx.logTags).toEqual(['x']); + expect(ctx.traceId).toBe('r1'); + expect(ctx.durationMs).toBe(42); + }); + + it('passes reconstructed Error to dd.error', () => { + const { dd, calls } = mockDatadog(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE })); + log.error('t', 'fail', { error: new Error('boom') }); + + expect(calls[0].error).toBeInstanceOf(Error); + expect(calls[0].error?.message).toBe('boom'); + }); +}); + +// ============================================================================ +// LOGTAIL +// ============================================================================ + +function mockLogtail(): { + lt: LogtailLike; + calls: { level: string; message: string; context: unknown }[]; +} { + const calls: { level: string; message: string; context: unknown }[] = []; + const lt: LogtailLike = { + debug(msg, ctx) { + calls.push({ level: 'debug', message: msg, context: ctx }); + }, + info(msg, ctx) { + calls.push({ level: 'info', message: msg, context: ctx }); + }, + warn(msg, ctx) { + calls.push({ level: 'warn', message: msg, context: ctx }); + }, + error(msg, ctx) { + calls.push({ level: 'error', message: msg, context: ctx }); + } + }; + return { lt, calls }; +} + +describe('logtailTransport', () => { + it('maps levels correctly', () => { + const { lt, calls } = mockLogtail(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(logtailTransport(lt, { minLevel: LogLevel.TRACE })); + + log.trace('t', 'a'); + log.debug('t', 'b'); + log.info('t', 'c'); + log.warn('t', 'd'); + log.error('t', 'e'); + log.fatal('t', 'f'); + + expect(calls.map((c) => c.level)).toEqual(['debug', 'debug', 'info', 'warn', 'error', 'error']); + }); + + it('includes logLevel, category and error in context', () => { + const { lt, calls } = mockLogtail(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(logtailTransport(lt, { minLevel: LogLevel.TRACE })); + log.error('auth', 'fail', { error: new Error('boom') }); + + const ctx = calls[0].context as Record; + expect(ctx.category).toBe('auth'); + expect(ctx.logLevel).toBe('ERROR'); + expect((ctx.error as { message: string }).message).toBe('boom'); + }); +}); + +// ============================================================================ +// LOKI +// ============================================================================ + +describe('lokiTransport', () => { + let originalFetch: typeof global.fetch; + let fetchMock: ReturnType; + + beforeEach(() => { + originalFetch = global.fetch; + fetchMock = vi.fn(async () => new Response('', { status: 204 })); + global.fetch = fetchMock as unknown as typeof global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('POSTs to /loki/api/v1/push with stream labels and JSON line', async () => { + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport( + lokiTransport({ + url: 'https://loki.example.com/loki/api/v1/push', + labels: { app: 'test' }, + minLevel: LogLevel.TRACE + }) + ); + + log.info('auth', 'hi'); + await new Promise((r) => setTimeout(r, 10)); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://loki.example.com/loki/api/v1/push'); + expect(init.method).toBe('POST'); + + const body = JSON.parse(init.body as string) as { + streams: { stream: Record; values: [string, string][] }[]; + }; + expect(body.streams[0].stream).toEqual({ app: 'test', level: 'info', category: 'auth' }); + const line = JSON.parse(body.streams[0].values[0][1]) as { message: string; level: string }; + expect(line.message).toBe('hi'); + expect(line.level).toBe('INFO'); + }); + + it('builds basic auth header from user/password', async () => { + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport( + lokiTransport({ + url: 'https://loki.example.com/push', + basicAuthUser: 'user', + basicAuthPassword: 'pass', + minLevel: LogLevel.TRACE + }) + ); + + log.info('t', 'x'); + await new Promise((r) => setTimeout(r, 10)); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + const expected = `Basic ${btoa('user:pass')}`; + expect(headers.Authorization).toBe(expected); + }); + + it('sends X-Scope-OrgID header when tenantId is provided', async () => { + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport( + lokiTransport({ + url: 'https://loki.example.com/push', + tenantId: 'tenant-1', + minLevel: LogLevel.TRACE + }) + ); + + log.info('t', 'x'); + await new Promise((r) => setTimeout(r, 10)); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers['X-Scope-OrgID']).toBe('tenant-1'); + }); + + it('rejects on non-2xx response (routed to deniedFor)', async () => { + fetchMock.mockResolvedValueOnce(new Response('bad', { status: 500 })); + + const seen: LogEntry[] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport({ name: 'capture', write: (e) => seen.push(e) }); + log.addTransport( + lokiTransport({ url: 'https://loki.example.com/push', minLevel: LogLevel.TRACE }) + ); + + log.info('t', 'hi'); + await new Promise((r) => setTimeout(r, 30)); + + const failure = seen.find((e) => e.deniedFor !== undefined); + expect(failure).toBeDefined(); + expect(failure?.deniedFor).toEqual(['loki']); + }); +}); + +// ============================================================================ +// OPENTELEMETRY +// ============================================================================ + +function mockOtelLogger(): { + otelLogger: OtelLoggerLike; + emitted: OtelLogRecord[]; +} { + const emitted: OtelLogRecord[] = []; + const otelLogger: OtelLoggerLike = { + emit(record) { + emitted.push(record); + } + }; + return { otelLogger, emitted }; +} + +describe('otelTransport', () => { + it('maps levels to OTel severityNumber (1/5/9/13/17/21)', () => { + const { otelLogger, emitted } = mockOtelLogger(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE })); + + log.trace('t', 'a'); + log.debug('t', 'b'); + log.info('t', 'c'); + log.warn('t', 'd'); + log.error('t', 'e'); + log.fatal('t', 'f'); + + expect(emitted.map((r) => r.severityNumber)).toEqual([1, 5, 9, 13, 17, 21]); + }); + + it('sets severityText to the level name', () => { + const { otelLogger, emitted } = mockOtelLogger(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE })); + log.info('t', 'hi'); + expect(emitted[0].severityText).toBe('INFO'); + }); + + it('flattens context as log.context.* attributes', () => { + const { otelLogger, emitted } = mockOtelLogger(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE })); + log.info('auth', 'hi', { context: { userId: 42, region: 'eu' } }); + + const attrs = emitted[0].attributes ?? {}; + expect(attrs['log.category']).toBe('auth'); + expect(attrs['log.context.userId']).toBe(42); + expect(attrs['log.context.region']).toBe('eu'); + }); + + it('maps error fields to exception.* attributes', () => { + const { otelLogger, emitted } = mockOtelLogger(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE })); + log.error('t', 'fail', { error: new Error('boom') }); + + const attrs = emitted[0].attributes ?? {}; + expect(attrs['exception.type']).toBe('Error'); + expect(attrs['exception.message']).toBe('boom'); + expect(attrs['exception.stacktrace']).toBeDefined(); + }); +}); diff --git a/src/arts/logr/adapters/datadog.ts b/src/arts/logr/adapters/datadog.ts new file mode 100644 index 0000000..a9d6a80 --- /dev/null +++ b/src/arts/logr/adapters/datadog.ts @@ -0,0 +1,100 @@ +import type { LogEntry, Transport } from '../types.ts'; +import { LogLevel } from '../types.ts'; + +/** + * Duck-typed subset of the `@datadog/browser-logs` SDK used by the transport. + * The caller injects the already-initialized SDK (normally `datadogLogs.logger`). + * + * @example + * import { datadogLogs } from '@datadog/browser-logs'; + * datadogLogs.init({ clientToken: '...', site: 'datadoghq.eu' }); + * logger.addTransport(datadogTransport(datadogLogs.logger)); + */ +export interface DatadogLoggerLike { + debug(message: string, messageContext?: Record): void; + info(message: string, messageContext?: Record): void; + warn(message: string, messageContext?: Record): void; + error(message: string, messageContext?: Record, error?: Error): void; +} + +export interface DatadogTransportOptions { + /** @default LogLevel.INFO — INFO/WARN/ERROR/FATAL reach the backend */ + minLevel?: LogLevel; + /** @default 'datadog' */ + name?: string; + filter?: (entry: LogEntry) => boolean; +} + +/** + * Transport that forwards entries to the `@datadog/browser-logs` SDK. + * + * The Datadog SDK handles batching, retries and throttling internally. This + * adapter maps levels to the SDK shortcuts and serializes the structured + * context into `messageContext`. Level mapping: + * - TRACE, DEBUG → `debug` + * - INFO → `info` + * - WARN → `warn` + * - ERROR, FATAL → `error` + * + * The original level is preserved in `messageContext.logLevel` for + * downstream routing/alerts. + * + * @example + * import { datadogLogs } from '@datadog/browser-logs'; + * datadogLogs.init({ + * clientToken: 'pubXXXXXXXXXX', + * site: 'datadoghq.eu', + * service: 'my-app', + * env: 'prod', + * version: '1.0.0' + * }); + * + * logger.addTransport(datadogTransport(datadogLogs.logger, { minLevel: LogLevel.WARN })); + */ +export function datadogTransport( + dd: DatadogLoggerLike, + options: DatadogTransportOptions = {} +): Transport { + return { + name: options.name ?? 'datadog', + minLevel: options.minLevel ?? LogLevel.INFO, + filter: options.filter, + write(entry: LogEntry): void { + // `source`, `duration`, `tags` are Datadog-reserved attributes — we + // use suffixed names to avoid collisions. + const messageContext: Record = { + category: entry.category, + logLevel: LogLevel[entry.level], + ...(entry.context ?? {}) + }; + if (entry.tags?.length) messageContext.logTags = entry.tags; + if (entry.traceId) messageContext.traceId = entry.traceId; + if (entry.durationMs !== undefined) messageContext.durationMs = entry.durationMs; + if (entry.source) messageContext.sourceLocation = entry.source; + + switch (entry.level) { + case LogLevel.TRACE: + case LogLevel.DEBUG: + dd.debug(entry.message, messageContext); + break; + case LogLevel.INFO: + dd.info(entry.message, messageContext); + break; + case LogLevel.WARN: + dd.warn(entry.message, messageContext); + break; + case LogLevel.ERROR: + case LogLevel.FATAL: + if (entry.error) { + const reconstructed = new Error(entry.error.message); + reconstructed.name = entry.error.name; + if (entry.error.stack) reconstructed.stack = entry.error.stack; + dd.error(entry.message, messageContext, reconstructed); + } else { + dd.error(entry.message, messageContext); + } + break; + } + } + }; +} diff --git a/src/arts/logr/adapters/logtail.ts b/src/arts/logr/adapters/logtail.ts new file mode 100644 index 0000000..de09220 --- /dev/null +++ b/src/arts/logr/adapters/logtail.ts @@ -0,0 +1,77 @@ +import type { LogEntry, Transport } from '../types.ts'; +import { LogLevel } from '../types.ts'; + +/** + * Duck-typed subset of the `@logtail/browser` SDK (Better Stack). + * + * @example + * import { Logtail } from '@logtail/browser'; + * const lt = new Logtail('source-token'); + * logger.addTransport(logtailTransport(lt)); + */ +export interface LogtailLike { + debug(message: string, context?: Record): void; + info(message: string, context?: Record): void; + warn(message: string, context?: Record): void; + error(message: string, context?: Record): void; +} + +export interface LogtailTransportOptions { + /** @default LogLevel.INFO */ + minLevel?: LogLevel; + /** @default 'logtail' */ + name?: string; + filter?: (entry: LogEntry) => boolean; +} + +/** + * Transport that forwards entries to Logtail (Better Stack). + * + * The Logtail SDK handles batching, retries and fingerprinting internally. + * Level mapping: + * - TRACE, DEBUG → `debug` + * - INFO → `info` + * - WARN → `warn` + * - ERROR, FATAL → `error` + * + * The original level is preserved in `context.logLevel` for downstream routing. + */ +export function logtailTransport( + lt: LogtailLike, + options: LogtailTransportOptions = {} +): Transport { + return { + name: options.name ?? 'logtail', + minLevel: options.minLevel ?? LogLevel.INFO, + filter: options.filter, + write(entry: LogEntry): void { + const context: Record = { + category: entry.category, + logLevel: LogLevel[entry.level], + ...(entry.context ?? {}) + }; + if (entry.tags?.length) context.logTags = entry.tags; + if (entry.traceId) context.traceId = entry.traceId; + if (entry.durationMs !== undefined) context.durationMs = entry.durationMs; + if (entry.source) context.sourceLocation = entry.source; + if (entry.error) context.error = entry.error; + + switch (entry.level) { + case LogLevel.TRACE: + case LogLevel.DEBUG: + lt.debug(entry.message, context); + break; + case LogLevel.INFO: + lt.info(entry.message, context); + break; + case LogLevel.WARN: + lt.warn(entry.message, context); + break; + case LogLevel.ERROR: + case LogLevel.FATAL: + lt.error(entry.message, context); + break; + } + } + }; +} diff --git a/src/arts/logr/adapters/loki.ts b/src/arts/logr/adapters/loki.ts new file mode 100644 index 0000000..6d05a7f --- /dev/null +++ b/src/arts/logr/adapters/loki.ts @@ -0,0 +1,153 @@ +import type { LogEntry, Transport } from '../types.ts'; +import { LogLevel } from '../types.ts'; + +export interface LokiTransportOptions { + /** Absolute URL of the push endpoint. Typically `https:///loki/api/v1/push`. */ + url: string; + /** + * Labels that identify the stream in Loki. Labels drive indexing and + * filtering — keep their cardinality low (never user ids, trace ids, etc.; + * those belong in the log line as JSON). + * + * @example { app: 'my-app', env: 'prod', source: 'browser' } + */ + labels?: Record; + /** Extra headers — useful for auth. */ + headers?: Record; + /** Basic auth user. Equivalent to `Authorization: Basic base64(user:pass)`. */ + basicAuthUser?: string; + basicAuthPassword?: string; + /** @default LogLevel.INFO */ + minLevel?: LogLevel; + /** @default 'loki' */ + name?: string; + filter?: (entry: LogEntry) => boolean; + /** + * Grafana Cloud / multi-tenant Loki tenant id. Sent as + * `X-Scope-OrgID` header. + */ + tenantId?: string; +} + +/** + * Transport that POSTs entries to the Grafana Loki push endpoint. + * + * - No SDK — plain `fetch` to `/loki/api/v1/push`. + * - The level is attached as a `level` label in the stream (low cardinality, + * safe). + * - The rest of the structured context is serialized as JSON in the log line, + * allowing LogQL queries like `{app="my-app"} | json | traceId="req-1"`. + * - Fire-and-forget from the caller's perspective — network failures bubble + * up through the promise, which the logger routes via `deniedFor`. + * + * @example + * logger.addTransport(lokiTransport({ + * url: 'https://logs-prod-006.grafana.net/loki/api/v1/push', + * labels: { app: 'my-app', env: 'prod' }, + * basicAuthUser: '123456', + * basicAuthPassword: 'eyJhbG...', + * tenantId: 'fake' + * })); + */ +export function lokiTransport(options: LokiTransportOptions): Transport { + const baseLabels = options.labels ?? {}; + const headers: Record = { + 'Content-Type': 'application/json', + ...options.headers + }; + + if (options.basicAuthUser !== undefined) { + const credentials = btoa(`${options.basicAuthUser}:${options.basicAuthPassword ?? ''}`); + headers.Authorization = `Basic ${credentials}`; + } + if (options.tenantId) { + headers['X-Scope-OrgID'] = options.tenantId; + } + + function serialize(entry: LogEntry): [string, string] { + const line = JSON.stringify({ + id: entry.id, + message: entry.message, + level: LogLevel[entry.level], + ...(entry.context ?? {}), + tags: entry.tags, + traceId: entry.traceId, + durationMs: entry.durationMs, + sourceLocation: entry.source, + error: entry.error + }); + const ns = String(entry.timestamp.getTime()) + '000000'; + return [ns, line]; + } + + async function push(streams: { stream: Record; values: [string, string][] }[]): Promise { + const res = await fetch(options.url, { + method: 'POST', + headers, + body: JSON.stringify({ streams }) + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Loki push failed: ${res.status} ${res.statusText} ${body}`); + } + } + + function streamKey(labels: Record): string { + // Stable JSON: keys sorted so identical label sets collapse into one stream. + return JSON.stringify(labels, Object.keys(labels).sort()); + } + + return { + name: options.name ?? 'loki', + minLevel: options.minLevel ?? LogLevel.INFO, + filter: options.filter, + async write(entry: LogEntry): Promise { + const streamLabels: Record = { + ...baseLabels, + level: levelLabel(entry.level), + category: entry.category + }; + await push([{ stream: streamLabels, values: [serialize(entry)] }]); + }, + async writeBatch(entries: LogEntry[]): Promise { + // Group entries by their stream labels — Loki stores timeseries by + // label set, so batching is cheapest when entries sharing labels + // collapse into a single stream. + const grouped = new Map; values: [string, string][] }>(); + for (const entry of entries) { + const labels: Record = { + ...baseLabels, + level: levelLabel(entry.level), + category: entry.category + }; + const key = streamKey(labels); + let bucket = grouped.get(key); + if (!bucket) { + bucket = { stream: labels, values: [] }; + grouped.set(key, bucket); + } + bucket.values.push(serialize(entry)); + } + await push([...grouped.values()]); + } + }; +} + +function levelLabel(level: LogLevel): string { + switch (level) { + case LogLevel.TRACE: + return 'trace'; + case LogLevel.DEBUG: + return 'debug'; + case LogLevel.INFO: + return 'info'; + case LogLevel.WARN: + return 'warn'; + case LogLevel.ERROR: + return 'error'; + case LogLevel.FATAL: + return 'fatal'; + default: + return 'info'; + } +} diff --git a/src/arts/logr/adapters/otel.ts b/src/arts/logr/adapters/otel.ts new file mode 100644 index 0000000..5006cf3 --- /dev/null +++ b/src/arts/logr/adapters/otel.ts @@ -0,0 +1,114 @@ +import type { LogEntry, Transport } from '../types.ts'; +import { LogLevel } from '../types.ts'; + +/** + * Duck-typed subset of the OpenTelemetry Logs API. The caller injects an + * already-configured OTel `Logger` (usually obtained from a `LoggerProvider`). + * + * @example + * import { logs } from '@opentelemetry/api-logs'; + * import { LoggerProvider } from '@opentelemetry/sdk-logs'; + * const provider = new LoggerProvider({ ... }); + * logs.setGlobalLoggerProvider(provider); + * const otelLogger = logs.getLogger('my-app'); + * logger.addTransport(otelTransport(otelLogger)); + */ +export interface OtelLoggerLike { + emit(record: OtelLogRecord): void; +} + +export interface OtelLogRecord { + /** OTel severityNumber. See spec: 1-4 trace, 5-8 debug, 9-12 info, 13-16 warn, 17-20 error, 21-24 fatal. */ + severityNumber?: number; + /** Human-readable severity text (TRACE, DEBUG, INFO, WARN, ERROR, FATAL). */ + severityText?: string; + body: string; + attributes?: Record; + /** Unix time in nanoseconds (or Date). */ + timestamp?: number | Date; +} + +export interface OtelTransportOptions { + /** @default LogLevel.INFO */ + minLevel?: LogLevel; + /** @default 'otel' */ + name?: string; + filter?: (entry: LogEntry) => boolean; +} + +/** + * Transport that forwards entries to an OpenTelemetry `Logger`. + * + * Maps our `LogLevel` to the OTel severityNumber space defined in the spec + * (each level has 4 numbers available; we use the base value of each band): + * - TRACE → 1 + * - DEBUG → 5 + * - INFO → 9 + * - WARN → 13 + * - ERROR → 17 + * - FATAL → 21 + * + * Structured context, tags, traceId and error are flattened into OTel + * attributes using dotted keys (`log.context.userId`, `log.tags`, ...). + */ +export function otelTransport( + otelLogger: OtelLoggerLike, + options: OtelTransportOptions = {} +): Transport { + return { + name: options.name ?? 'otel', + minLevel: options.minLevel ?? LogLevel.INFO, + filter: options.filter, + write(entry: LogEntry): void { + const attributes: Record = { + 'log.category': entry.category + }; + + if (entry.context) { + for (const [key, value] of Object.entries(entry.context)) { + attributes[`log.context.${key}`] = value; + } + } + if (entry.tags?.length) attributes['log.tags'] = entry.tags; + if (entry.traceId) attributes['log.traceId'] = entry.traceId; + if (entry.durationMs !== undefined) attributes['log.durationMs'] = entry.durationMs; + if (entry.source) { + if (entry.source.file) attributes['code.filepath'] = entry.source.file; + if (entry.source.line !== undefined) attributes['code.lineno'] = entry.source.line; + if (entry.source.function) attributes['code.function'] = entry.source.function; + } + if (entry.error) { + attributes['exception.type'] = entry.error.name; + attributes['exception.message'] = entry.error.message; + if (entry.error.stack) attributes['exception.stacktrace'] = entry.error.stack; + } + + otelLogger.emit({ + severityNumber: toOtelSeverityNumber(entry.level), + severityText: LogLevel[entry.level], + body: entry.message, + attributes, + timestamp: entry.timestamp + }); + } + }; +} + +function toOtelSeverityNumber(level: LogLevel): number { + switch (level) { + case LogLevel.TRACE: + return 1; + case LogLevel.DEBUG: + return 5; + case LogLevel.INFO: + return 9; + case LogLevel.WARN: + return 13; + case LogLevel.ERROR: + return 17; + case LogLevel.FATAL: + return 21; + default: + return 9; + } +} diff --git a/src/arts/logr/adapters/sentry.ts b/src/arts/logr/adapters/sentry.ts new file mode 100644 index 0000000..67bb8cd --- /dev/null +++ b/src/arts/logr/adapters/sentry.ts @@ -0,0 +1,149 @@ +import type { LogEntry, Transport } from '../types.ts'; +import { LogLevel } from '../types.ts'; + +/** + * Duck-typed subset of the Sentry SDK used by the transport. The `engine_logger` + * library does not import `@sentry/browser` directly — the caller injects an + * already-initialized SDK. This keeps the logger zero-deps and makes testing + * with trivial mocks easy. + */ +export interface SentryLike { + captureException(err: unknown): void; + captureMessage(msg: string, level?: SentrySeverity): void; + addBreadcrumb(breadcrumb: { + message?: string; + level?: SentrySeverity; + category?: string; + data?: Record; + timestamp?: number; + }): void; + withScope(cb: (scope: SentryScopeLike) => void): void; +} + +export interface SentryScopeLike { + setTag(key: string, value: string): void; + setContext(name: string, context: Record | null): void; + setFingerprint?(fingerprint: string[]): void; +} + +export type SentrySeverity = 'debug' | 'info' | 'warning' | 'error' | 'fatal'; + +export interface SentryTransportOptions { + /** + * Entries at or above `minLevel` become Sentry events (captureException/ + * captureMessage). + * @default LogLevel.ERROR + */ + minLevel?: LogLevel; + /** + * Entries in `[breadcrumbLevel, minLevel)` become breadcrumbs. Breadcrumbs + * are context attached to the next captured event. Pass `LogLevel.NONE` to + * disable them entirely. + * @default LogLevel.INFO + */ + breadcrumbLevel?: LogLevel; + /** Transport name shown in introspection and `deniedFor`. @default 'sentry' */ + name?: string; + /** Extra predicate. When it returns `false`, the entry is skipped entirely. */ + filter?: (entry: LogEntry) => boolean; +} + +/** + * Transport that forwards entries to the Sentry SDK. + * + * - ERROR+ → `captureException` when `entry.error` is set, `captureMessage` + * otherwise. + * - INFO..WARN → `addBreadcrumb` (attached to the next captured event). + * - DEBUG/TRACE → `addBreadcrumb` with severity 'debug' (only if + * `breadcrumbLevel` is low enough). + * + * Every event passes through `withScope` so that tags, traceId and context + * are attached without polluting Sentry's global scope. + * + * @example + * import * as Sentry from '@sentry/browser'; + * Sentry.init({ dsn: '...' }); + * + * const logger = createEngineLogger({ level: LogLevel.DEBUG }); + * logger.addTransport(sentryTransport(Sentry)); + * + * logger.info('auth', 'Login attempt'); // breadcrumb + * logger.error('auth', 'Login failed', { error: err }); // event + breadcrumbs + */ +export function sentryTransport( + sentry: SentryLike, + options: SentryTransportOptions = {} +): Transport { + const minLevel = options.minLevel ?? LogLevel.ERROR; + const breadcrumbLevel = options.breadcrumbLevel ?? LogLevel.INFO; + const name = options.name ?? 'sentry'; + + return { + name, + // Intentionally no `transport.minLevel` here — we need to receive lower + // levels too so they can become breadcrumbs. + filter: options.filter, + write(entry: LogEntry): void { + if (entry.level < breadcrumbLevel) return; + + if (entry.level < minLevel) { + sentry.addBreadcrumb({ + message: entry.message, + level: toSentrySeverity(entry.level), + category: entry.category, + data: entry.context, + timestamp: entry.timestamp.getTime() / 1000 + }); + return; + } + + sentry.withScope((scope) => { + scope.setTag('category', entry.category); + if (entry.traceId) scope.setTag('traceId', entry.traceId); + if (entry.tags?.length) { + for (const t of entry.tags) scope.setTag(`tag:${t}`, 'true'); + } + if (entry.context) scope.setContext('log.context', entry.context); + if (entry.source) { + scope.setContext( + 'log.source', + entry.source as unknown as Record + ); + } + if (entry.durationMs !== undefined) { + scope.setContext('log.timing', { durationMs: entry.durationMs }); + } + + if (entry.error) { + const reconstructed = new Error(entry.error.message); + reconstructed.name = entry.error.name; + if (entry.error.stack) reconstructed.stack = entry.error.stack; + if (entry.error.cause !== undefined) { + (reconstructed as { cause?: unknown }).cause = entry.error.cause; + } + sentry.captureException(reconstructed); + } else { + sentry.captureMessage(entry.message, toSentrySeverity(entry.level)); + } + }); + } + }; +} + +function toSentrySeverity(level: LogLevel): SentrySeverity { + switch (level) { + case LogLevel.TRACE: + case LogLevel.DEBUG: + return 'debug'; + case LogLevel.INFO: + return 'info'; + case LogLevel.WARN: + return 'warning'; + case LogLevel.ERROR: + return 'error'; + case LogLevel.FATAL: + return 'fatal'; + default: + return 'info'; + } +} diff --git a/src/arts/logr/consts.ts b/src/arts/logr/consts.ts new file mode 100644 index 0000000..d089836 --- /dev/null +++ b/src/arts/logr/consts.ts @@ -0,0 +1,10 @@ +/** + * Runtime identifier of the logging engine. Used for: + * - `[engine_logger]` prefix in console output + * - Category of internal entries (transport failures, etc.) + * - `engine_logger` tag on synthetic entries + * + * Independent of the folder/alias name (`logr`, `$logr`): the directory is a + * filesystem detail; this identifier describes the engine itself. + */ +export const logger = 'engine_logger'; diff --git a/src/arts/logr/engine-logger.ts b/src/arts/logr/engine-logger.ts new file mode 100644 index 0000000..9a0b1ca --- /dev/null +++ b/src/arts/logr/engine-logger.ts @@ -0,0 +1,466 @@ +import type { + LoggerOptions, + LogEntry, + LogInput, + LogMessage, + MessageCategory, + LogFilters, + EngineLogger, + Transport, + SubscriberFn, + LogSource +} from './types.ts'; +import { LogLevel } from './types.ts'; +import { logger } from './consts.ts'; +import { consoleTransport } from './transports.ts'; +import { captureSource, extractError } from './source.ts'; + +/** + * Unique entry id. Uses `crypto.randomUUID()` when available (modern browsers, + * Node 16+); falls back to a compact `timestamp-random` hex in older hosts. + */ +function generateId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +const DEV: boolean = + typeof import.meta !== 'undefined' && import.meta.env != null + ? import.meta.env.DEV === true + : typeof process !== 'undefined' && process.env?.NODE_ENV === 'development'; + +// ============================================================================ +// ENGINE +// ============================================================================ + +/** + * Create an `EngineLogger` instance. + * + * **Decoupled from i18n** — messages are plain strings. If you need + * translated log lines, resolve them at the call site: + * `logger.info('auth', lang.t('login.ok'))` + * + * Features: + * - `globalContext` merged into every entry (appVersion, sessionId, userId…). + * - `source` auto-captured from the stack in DEV (`file:line:col`). + * - `error` normalized to a serializable `LogError` when passed in `input.error`. + * - `traceId` and `tags` for correlation and fine-grained filtering. + * - `child(ctx)` — child logger with extended context, shares history/transports. + * - `time(label)` / `timeEnd(label)` — duration measurement. + * - `addTransport(t)` / `subscribe(fn)` — dynamic bus (Sentry, metrics, etc.). + * - Per-transport `filter` and `minLevel` — e.g. Sentry only ERROR+. + * - Lazy messages via thunks — not evaluated if filtered by global level. + * + * @example + * const log = createEngineLogger({ + * level: LogLevel.DEBUG, + * globalContext: { appVersion: '1.2.3', env: 'prod' }, + * transports: [consoleTransport()] + * }); + * + * // Sentry as a dynamic transport + * const detachSentry = log.addTransport({ + * name: 'sentry', + * minLevel: LogLevel.ERROR, + * write: (entry) => { + * if (entry.error) Sentry.captureException(entry.error); + * else Sentry.captureMessage(entry.message); + * } + * }); + * + * // Pure observer via subscribe + * log.subscribe((entry) => { + * if (entry.level < LogLevel.ERROR) Sentry.addBreadcrumb({ message: entry.message }); + * }); + * + * try { ... } catch (err) { + * log.error('db', 'Query failed', { error: err, traceId: 'req-1' }); + * } + */ +export function createEngineLogger(options: LoggerOptions = {}): EngineLogger { + const captureSourceEnabled: boolean = options.captureSource ?? DEV; + const timers = new Map(); + + // Per-transport buffers and interval timer ids — indexed by reference. + const buffers = new WeakMap(); + const flushTimers = new WeakMap>(); + + // Mutable state shared between root logger and its children. + const state = { + level: options.level ?? LogLevel.WARN, + maxLogs: options.maxLogs ?? 1000, + entries: [] as LogEntry[], + transports: [...(options.transports ?? [consoleTransport()])] + }; + + const built = buildLogger( + captureSourceEnabled, + timers, + buffers, + flushTimers, + state, + { ...options.globalContext } + ); + + // Browser: flush everything before the page unloads so buffered entries + // get a chance to reach their destinations. + if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('beforeunload', () => built.flush()); + } + + return built; +} + +interface LoggerState { + level: LogLevel; + maxLogs: number; + entries: LogEntry[]; + transports: Transport[]; +} + +function buildLogger( + captureSourceEnabled: boolean, + timers: Map, + buffers: WeakMap, + flushTimers: WeakMap>, + state: LoggerState, + globalContext: Record +): EngineLogger { + function mergeContext(input?: LogInput): Record | undefined { + const inputCtx = input?.context; + const hasGlobal = Object.keys(globalContext).length > 0; + if (!inputCtx && !hasGlobal) return undefined; + return { ...globalContext, ...inputCtx }; + } + + function resolveMessage(message: LogMessage): string { + if (typeof message === 'function') { + try { + return message(); + } catch (err) { + return `[${logger}] thunk threw: ${err instanceof Error ? err.message : String(err)}`; + } + } + return message; + } + + function log( + lvl: LogLevel, + category: MessageCategory, + message: LogMessage, + input: LogInput | undefined, + skipFrames: number + ): void { + // Global level check first: if dropped, skip thunk evaluation and source capture. + if (lvl < state.level) return; + + const resolved = resolveMessage(message); + const source: LogSource | undefined = + input?.source ?? (captureSourceEnabled ? captureSource(skipFrames) : undefined); + + const entry: LogEntry = { + id: generateId(), + timestamp: new Date(), + level: lvl, + category, + message: resolved, + context: mergeContext(input), + error: input?.error !== undefined ? extractError(input.error) : undefined, + tags: input?.tags, + traceId: input?.traceId, + durationMs: input?.durationMs, + source + }; + + pushEntry(entry); + dispatch(entry, undefined); + } + + function pushEntry(entry: LogEntry): void { + state.entries.push(entry); + if (state.entries.length > state.maxLogs) { + state.entries = state.entries.slice(-state.maxLogs); + } + } + + /** + * Dispatch an entry to the active transports. + * + * - `skipTransport` excludes a specific transport by reference (used when + * re-injecting a failure entry so the transport that failed does not + * receive the report of its own error — prevents cascades). + * - Sync throws and promise rejections are routed to the same failure handler, + * except when the entry is already a failure report (`deniedFor` set): + * a second-level failure is swallowed to guarantee no cascade loop. + * - If `transport.buffer > 0`, entries are queued and flushed when the + * buffer is full or the interval timer fires. + */ + function dispatch(entry: LogEntry, skipTransport: Transport | undefined): void { + const isFailureReport = entry.deniedFor !== undefined; + + for (const transport of state.transports) { + if (transport === skipTransport) continue; + if (transport.minLevel !== undefined && entry.level < transport.minLevel) continue; + if (transport.filter && !transport.filter(entry)) continue; + + const bufferSize = transport.buffer ?? 0; + if (bufferSize <= 0) { + writeOne(transport, entry, isFailureReport); + } else { + enqueue(transport, entry, bufferSize, isFailureReport); + } + } + } + + function writeOne(transport: Transport, entry: LogEntry, isFailureReport: boolean): void { + try { + const ret = transport.write(entry); + if (ret && typeof ret.then === 'function') { + ret.catch((err: unknown) => handleFailure(transport, err, isFailureReport)); + } + } catch (err) { + handleFailure(transport, err, isFailureReport); + } + } + + function enqueue( + transport: Transport, + entry: LogEntry, + maxSize: number, + isFailureReport: boolean + ): void { + let buf = buffers.get(transport); + if (!buf) { + buf = []; + buffers.set(transport, buf); + } + buf.push(entry); + + // Arm the time-based flush timer on the first enqueue of the cycle. + if ( + transport.flushIntervalMs !== undefined && + transport.flushIntervalMs > 0 && + !flushTimers.has(transport) + ) { + const id = setTimeout(() => flushTransport(transport, isFailureReport), transport.flushIntervalMs); + flushTimers.set(transport, id); + } + + if (buf.length >= maxSize) { + flushTransport(transport, isFailureReport); + } + } + + function flushTransport(transport: Transport, isFailureReport: boolean): void { + const buf = buffers.get(transport); + if (!buf || buf.length === 0) { + clearFlushTimer(transport); + return; + } + + // Take ownership of the buffer and clear the pending timer. + buffers.delete(transport); + clearFlushTimer(transport); + + if (transport.writeBatch) { + try { + const ret = transport.writeBatch(buf); + if (ret && typeof ret.then === 'function') { + ret.catch((err: unknown) => handleFailure(transport, err, isFailureReport)); + } + } catch (err) { + handleFailure(transport, err, isFailureReport); + } + } else { + for (const entry of buf) writeOne(transport, entry, isFailureReport); + } + } + + function clearFlushTimer(transport: Transport): void { + const id = flushTimers.get(transport); + if (id !== undefined) { + clearTimeout(id); + flushTimers.delete(transport); + } + } + + function flushAll(): void { + for (const transport of state.transports) { + flushTransport(transport, false); + } + } + + function handleFailure( + failed: Transport, + err: unknown, + suppressFailureReport: boolean + ): void { + const failedName = failed.name ?? 'anonymous'; + console.error(`[${logger}] Transport "${failedName}" error:`, err); + + // Cascade guard: if the failure happens while re-dispatching a failure + // entry, do not synthesize a second report. Console-only. + if (suppressFailureReport) return; + + const errorObj = extractError(err); + const failureEntry: LogEntry = { + id: generateId(), + timestamp: new Date(), + level: LogLevel.ERROR, + category: logger, + message: `Transport "${failedName}" failed: ${errorObj.message}`, + context: mergeContext(undefined), + error: errorObj, + tags: [logger, 'transport-failure'], + deniedFor: [failedName] + }; + + pushEntry(failureEntry); + dispatch(failureEntry, failed); + } + + return { + trace(category, message, input) { + log(LogLevel.TRACE, category, message, input, 3); + }, + debug(category, message, input) { + log(LogLevel.DEBUG, category, message, input, 3); + }, + info(category, message, input) { + log(LogLevel.INFO, category, message, input, 3); + }, + warn(category, message, input) { + log(LogLevel.WARN, category, message, input, 3); + }, + error(category, message, input) { + log(LogLevel.ERROR, category, message, input, 3); + }, + fatal(category, message, input) { + log(LogLevel.FATAL, category, message, input, 3); + }, + + getLogs(filters?: LogFilters): LogEntry[] { + let result = state.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!); + if (filters?.tag !== undefined) { + const tag = filters.tag; + result = result.filter((e) => e.tags?.includes(tag) ?? false); + } + if (filters?.traceId !== undefined) + result = result.filter((e) => e.traceId === filters.traceId); + + return result; + }, + + clear(): void { + state.entries = []; + }, + + serialize(): string { + const seen = new WeakSet(); + const safeReplacer = (_key: string, value: unknown) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) return '[Circular]'; + seen.add(value); + } + return value; + }; + + return JSON.stringify( + state.entries.map((e) => ({ + ...e, + timestamp: e.timestamp.toISOString() + })), + safeReplacer, + 2 + ); + }, + + setLevel(l: LogLevel): void { + state.level = l; + }, + + setMaxLogs(max: number): void { + const normalized = !Number.isFinite(max) || max < 1 ? 1 : Math.floor(max); + state.maxLogs = normalized; + if (state.entries.length > normalized) { + state.entries = state.entries.slice(-normalized); + } + }, + + setGlobalContext(ctx: Record): void { + globalContext = { ...ctx }; + }, + + addTransport(transport: Transport): () => void { + state.transports.push(transport); + return () => { + // Flush anything buffered so data isn't lost on detach. + flushTransport(transport, false); + const idx = state.transports.indexOf(transport); + if (idx !== -1) state.transports.splice(idx, 1); + }; + }, + + subscribe(fn: SubscriberFn): () => void { + const transport: Transport = { write: fn, name: 'subscriber' }; + state.transports.push(transport); + return () => { + flushTransport(transport, false); + const idx = state.transports.indexOf(transport); + if (idx !== -1) state.transports.splice(idx, 1); + }; + }, + + removeAllTransports(): void { + flushAll(); + state.transports = []; + }, + + transports(): readonly Transport[] { + return state.transports; + }, + + child(ctx: Record): EngineLogger { + return buildLogger(captureSourceEnabled, timers, buffers, flushTimers, state, { + ...globalContext, + ...ctx + }); + }, + + time(label: string): void { + timers.set(label, performance.now()); + }, + + timeEnd(label: string, category: MessageCategory = 'timer', message?: LogMessage): void { + const start = timers.get(label); + if (start === undefined) { + log( + LogLevel.WARN, + 'timer', + `[${logger}] timeEnd("${label}") called without matching time()`, + undefined, + 3 + ); + return; + } + timers.delete(label); + const durationMs = performance.now() - start; + const msg = message ?? label; + log(LogLevel.INFO, category, msg, { durationMs, tags: ['timer'] }, 3); + }, + + flush(): void { + flushAll(); + } + }; +} diff --git a/src/arts/logr/index.ts b/src/arts/logr/index.ts new file mode 100644 index 0000000..25b2a4b --- /dev/null +++ b/src/arts/logr/index.ts @@ -0,0 +1,3 @@ +export * from './engine-logger.ts'; +export * from './transports.ts'; +export * from './types.ts'; diff --git a/src/arts/logr/source.ts b/src/arts/logr/source.ts new file mode 100644 index 0000000..2a62c63 --- /dev/null +++ b/src/arts/logr/source.ts @@ -0,0 +1,99 @@ +import type { LogSource } from './types.ts'; + +/** + * Capture the caller location (file:line:column) by parsing `new Error().stack`. + * + * @param skip Number of internal frames to skip before the actual caller. + * Depends on the call depth inside the logger itself. + * + * V8 format (Chrome/Node/Edge): + * " at fnName (file:///path:10:15)" + * " at file:///path:10:15" + * + * SpiderMonkey/JSC format (Firefox/Safari): + * "fnName@file:///path:10:15" + * + * In minified production builds the function names are mangled — the caller + * can opt out via `captureSource: false` in LoggerOptions. + */ +export function captureSource(skip: number): LogSource | undefined { + const err = new Error(); + const stack = err.stack; + if (!stack) return undefined; + + const lines = stack.split('\n'); + // First line is either "Error" (V8) or the first frame (SpiderMonkey). + const startIdx = lines[0]?.trim().startsWith('at ') || lines[0]?.includes('@') ? 0 : 1; + const frameLine = lines[startIdx + skip]; + if (!frameLine) return undefined; + + return parseFrame(frameLine); +} + +function parseFrame(frame: string): LogSource | undefined { + const trimmed = frame.trim(); + + // V8: "at fnName (file:line:col)" or "at file:line:col" + const v8Match = + trimmed.match(/^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/) ?? + trimmed.match(/^at\s+(.+):(\d+):(\d+)$/); + + if (v8Match) { + if (v8Match.length === 5) { + return { + function: v8Match[1], + file: v8Match[2], + line: Number(v8Match[3]), + column: Number(v8Match[4]) + }; + } + return { + file: v8Match[1], + line: Number(v8Match[2]), + column: Number(v8Match[3]) + }; + } + + // SpiderMonkey/JSC: "fnName@file:line:col" or "@file:line:col" + const mozMatch = trimmed.match(/^(.*?)@(.+):(\d+):(\d+)$/); + if (mozMatch) { + const fn = mozMatch[1]; + return { + function: fn || undefined, + file: mozMatch[2], + line: Number(mozMatch[3]), + column: Number(mozMatch[4]) + }; + } + + return undefined; +} + +/** + * Extract an `Error` (or any value caught in a try/catch) into the + * serializable `LogError` shape. Accepts `unknown` because thrown values are + * not always `Error` instances — strings, objects, etc. + */ +export function extractError(value: unknown): import('./types.ts').LogError { + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + cause: value.cause + }; + } + if (typeof value === 'object' && value !== null) { + const obj = value as { name?: unknown; message?: unknown; stack?: unknown; cause?: unknown }; + return { + name: typeof obj.name === 'string' ? obj.name : 'Unknown', + message: typeof obj.message === 'string' ? obj.message : String(value), + stack: typeof obj.stack === 'string' ? obj.stack : undefined, + cause: obj.cause + }; + } + return { + name: 'Unknown', + message: String(value) + }; +} diff --git a/src/arts/logr/test/logr.test.ts b/src/arts/logr/test/logr.test.ts new file mode 100644 index 0000000..103f535 --- /dev/null +++ b/src/arts/logr/test/logr.test.ts @@ -0,0 +1,733 @@ +/** + * logr — test suite + * + * Covers: + * - Level filtering (global) + * - All six level methods (trace..fatal) + * - globalContext merge + * - Lazy messages (thunks) + * - source capture + * - error extraction + * - tags, traceId + * - time / timeEnd + * - child() + * - getLogs filters + * - setLevel, setMaxLogs, setGlobalContext + * - addTransport, subscribe, removeAllTransports, transports() + * - Per-transport minLevel + filter + * - Transport failure → deniedFor (sync + async) + * - Cascade guard + * - serialize() + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + LogLevel, + createEngineLogger, + callbackTransport, + type LogEntry, + type Transport +} from '../index.ts'; +import { extractError } from '../source.ts'; +import { logger as LOGGER_NAME } from '../consts.ts'; + +function capture(): { transport: Transport; entries: LogEntry[] } { + const entries: LogEntry[] = []; + const transport: Transport = { + name: 'capture', + write(entry) { + entries.push(entry); + } + }; + return { transport, entries }; +} + +describe('createEngineLogger — level methods', () => { + it('emits each of the 6 levels', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + + log.trace('t', 'trace'); + log.debug('t', 'debug'); + log.info('t', 'info'); + log.warn('t', 'warn'); + log.error('t', 'error'); + log.fatal('t', 'fatal'); + + expect(entries.map((e) => e.level)).toEqual([ + LogLevel.TRACE, + LogLevel.DEBUG, + LogLevel.INFO, + LogLevel.WARN, + LogLevel.ERROR, + LogLevel.FATAL + ]); + }); + + it('drops entries below the global level', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.WARN, transports: [transport] }); + + log.trace('t', 'dropped'); + log.debug('t', 'dropped'); + log.info('t', 'dropped'); + log.warn('t', 'kept'); + log.error('t', 'kept'); + log.fatal('t', 'kept'); + + expect(entries).toHaveLength(3); + expect(entries.map((e) => e.level)).toEqual([LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]); + }); + + it('setLevel raises/lowers the filter at runtime', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + + log.info('t', 'one'); + log.setLevel(LogLevel.ERROR); + log.info('t', 'dropped'); + log.error('t', 'two'); + + expect(entries.map((e) => e.message)).toEqual(['one', 'two']); + }); +}); + +describe('createEngineLogger — lazy messages', () => { + it('does not evaluate the thunk when filtered by level', () => { + const { transport } = capture(); + const log = createEngineLogger({ level: LogLevel.ERROR, transports: [transport] }); + const thunk = vi.fn(() => 'expensive'); + log.debug('t', thunk); + expect(thunk).not.toHaveBeenCalled(); + }); + + it('evaluates the thunk when the entry passes', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('t', () => 'expensive'); + expect(entries[0].message).toBe('expensive'); + }); + + it('captures thunk errors as the resolved message', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('t', () => { + throw new Error('boom'); + }); + expect(entries[0].message).toContain('thunk threw'); + expect(entries[0].message).toContain('boom'); + }); +}); + +describe('createEngineLogger — globalContext', () => { + it('merges globalContext into every entry context', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ + level: LogLevel.TRACE, + globalContext: { appVersion: '1.0.0' }, + transports: [transport] + }); + log.info('t', 'hello', { context: { userId: 42 } }); + expect(entries[0].context).toEqual({ appVersion: '1.0.0', userId: 42 }); + }); + + it('call-site context wins on key collision', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ + level: LogLevel.TRACE, + globalContext: { env: 'dev' }, + transports: [transport] + }); + log.info('t', 'hello', { context: { env: 'prod' } }); + expect(entries[0].context).toEqual({ env: 'prod' }); + }); + + it('setGlobalContext replaces the existing context', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ + level: LogLevel.TRACE, + globalContext: { a: 1 }, + transports: [transport] + }); + log.setGlobalContext({ b: 2 }); + log.info('t', 'hello'); + expect(entries[0].context).toEqual({ b: 2 }); + }); + + it('entry has undefined context when no global and no local context exist', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('t', 'hello'); + expect(entries[0].context).toBeUndefined(); + }); +}); + +describe('createEngineLogger — error / traceId / tags / durationMs', () => { + it('extracts an Error to LogError shape', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + const err = new Error('boom'); + log.error('t', 'failed', { error: err }); + expect(entries[0].error).toMatchObject({ name: 'Error', message: 'boom' }); + expect(entries[0].error?.stack).toBeDefined(); + }); + + it('accepts non-Error values as error', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.error('t', 'failed', { error: 'string-err' }); + expect(entries[0].error?.message).toBe('string-err'); + }); + + it('preserves traceId, tags and durationMs on the entry', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('t', 'hello', { + traceId: 'req-1', + tags: ['auth', 'critical'], + durationMs: 123 + }); + expect(entries[0].traceId).toBe('req-1'); + expect(entries[0].tags).toEqual(['auth', 'critical']); + expect(entries[0].durationMs).toBe(123); + }); +}); + +describe('extractError', () => { + it('handles Error instances', () => { + const err = new Error('boom'); + const result = extractError(err); + expect(result.name).toBe('Error'); + expect(result.message).toBe('boom'); + expect(result.stack).toBeDefined(); + }); + + it('handles strings', () => { + expect(extractError('oops')).toMatchObject({ name: 'Unknown', message: 'oops' }); + }); + + it('handles plain objects with message', () => { + expect(extractError({ message: 'obj-fail' })).toMatchObject({ message: 'obj-fail' }); + }); +}); + +describe('createEngineLogger — time / timeEnd', () => { + it('emits an INFO entry with durationMs and timer tag', async () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + + log.time('op'); + await new Promise((r) => setTimeout(r, 10)); + log.timeEnd('op', 'perf', 'operation done'); + + const last = entries[entries.length - 1]; + expect(last.level).toBe(LogLevel.INFO); + expect(last.category).toBe('perf'); + expect(last.message).toBe('operation done'); + expect(last.durationMs).toBeGreaterThan(0); + expect(last.tags).toEqual(['timer']); + }); + + it('warns when timeEnd has no matching time', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.timeEnd('missing'); + expect(entries[0].level).toBe(LogLevel.WARN); + expect(entries[0].message).toContain('timeEnd'); + }); +}); + +describe('createEngineLogger — child()', () => { + it('extends globalContext and shares history', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ + level: LogLevel.TRACE, + globalContext: { app: 'X' }, + transports: [transport] + }); + const child = log.child({ requestId: 'r1' }); + child.info('t', 'via child'); + log.info('t', 'via parent'); + + expect(entries).toHaveLength(2); + expect(entries[0].context).toEqual({ app: 'X', requestId: 'r1' }); + expect(entries[1].context).toEqual({ app: 'X' }); + }); + + it('children share the transports list with the parent', () => { + const log = createEngineLogger({ level: LogLevel.TRACE }); + const child = log.child({}); + const parentCount = log.transports().length; + child.addTransport({ name: 'extra', write: () => {} }); + expect(log.transports().length).toBe(parentCount + 1); + }); +}); + +describe('createEngineLogger — getLogs filters', () => { + it('filters by exact level', () => { + const { transport } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.debug('t', 'a'); + log.info('t', 'b'); + log.debug('t', 'c'); + expect(log.getLogs({ level: LogLevel.DEBUG })).toHaveLength(2); + }); + + it('filters by minLevel', () => { + const { transport } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('t', 'a'); + log.warn('t', 'b'); + log.error('t', 'c'); + expect(log.getLogs({ minLevel: LogLevel.WARN })).toHaveLength(2); + }); + + it('filters by category', () => { + const { transport } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('auth', 'a'); + log.info('db', 'b'); + expect(log.getLogs({ category: 'auth' })).toHaveLength(1); + }); + + it('filters by tag', () => { + const { transport } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('t', 'a', { tags: ['x', 'y'] }); + log.info('t', 'b', { tags: ['y', 'z'] }); + expect(log.getLogs({ tag: 'x' })).toHaveLength(1); + expect(log.getLogs({ tag: 'y' })).toHaveLength(2); + }); + + it('filters by traceId', () => { + const { transport } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('t', 'a', { traceId: '1' }); + log.info('t', 'b', { traceId: '2' }); + expect(log.getLogs({ traceId: '1' })).toHaveLength(1); + }); + + it('filters by since', async () => { + const { transport } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('t', 'a'); + await new Promise((r) => setTimeout(r, 2)); + const now = new Date(); + await new Promise((r) => setTimeout(r, 2)); + log.info('t', 'b'); + expect(log.getLogs({ since: now })).toHaveLength(1); + }); + + it('combines filters with AND', () => { + const { transport } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('auth', 'a'); + log.warn('auth', 'b'); + log.error('db', 'c'); + expect(log.getLogs({ category: 'auth', minLevel: LogLevel.WARN })).toHaveLength(1); + }); + + it('returns a defensive copy', () => { + const { transport } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('t', 'hi'); + const logs = log.getLogs(); + logs[0].message = 'modified'; + expect(log.getLogs()[0].message).toBe('hi'); + }); +}); + +describe('createEngineLogger — maxLogs', () => { + it('trims history to maxLogs', () => { + const { transport } = capture(); + const log = createEngineLogger({ + level: LogLevel.TRACE, + maxLogs: 3, + transports: [transport] + }); + for (let i = 0; i < 5; i++) log.info('t', String(i)); + expect(log.getLogs()).toHaveLength(3); + expect(log.getLogs().map((l) => l.message)).toEqual(['2', '3', '4']); + }); + + it('setMaxLogs trims immediately when shrinking', () => { + const { transport } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + for (let i = 0; i < 4; i++) log.info('t', String(i)); + log.setMaxLogs(2); + expect(log.getLogs()).toHaveLength(2); + expect(log.getLogs().map((l) => l.message)).toEqual(['2', '3']); + }); +}); + +describe('createEngineLogger — dynamic transports', () => { + it('addTransport receives entries emitted afterwards', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.info('t', 'before'); + log.addTransport(transport); + log.info('t', 'after'); + expect(entries.map((e) => e.message)).toEqual(['after']); + }); + + it('addTransport remove function detaches it', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + const remove = log.addTransport(transport); + log.info('t', 'one'); + remove(); + log.info('t', 'two'); + expect(entries.map((e) => e.message)).toEqual(['one']); + }); + + it('subscribe is sugar over addTransport', () => { + const received: LogEntry[] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + const unsub = log.subscribe((e) => received.push(e)); + log.info('t', 'x'); + unsub(); + log.info('t', 'y'); + expect(received.map((e) => e.message)).toEqual(['x']); + }); + + it('removeAllTransports detaches everything', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('t', 'a'); + log.removeAllTransports(); + log.info('t', 'b'); + expect(entries.map((e) => e.message)).toEqual(['a']); + }); + + it('transports() returns a read-only view', () => { + const { transport } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + expect(log.transports().length).toBe(1); + expect(log.transports()[0].name).toBe('capture'); + }); +}); + +describe('createEngineLogger — per-transport minLevel and filter', () => { + it('drops entries below transport.minLevel', () => { + const entries: LogEntry[] = []; + const log = createEngineLogger({ level: LogLevel.TRACE }); + log.removeAllTransports(); + log.addTransport({ name: 't', minLevel: LogLevel.WARN, write: (e) => entries.push(e) }); + log.info('t', 'dropped'); + log.warn('t', 'kept'); + expect(entries.map((e) => e.message)).toEqual(['kept']); + }); + + it('drops entries when predicate returns false', () => { + const entries: LogEntry[] = []; + const log = createEngineLogger({ level: LogLevel.TRACE }); + log.removeAllTransports(); + log.addTransport({ + name: 't', + filter: (e) => e.category === 'kept', + write: (e) => entries.push(e) + }); + log.info('dropped', 'a'); + log.info('kept', 'b'); + expect(entries.map((e) => e.category)).toEqual(['kept']); + }); +}); + +describe('createEngineLogger — transport failure and deniedFor', () => { + it('sync throw routes a failure entry to the rest', () => { + const seenByOther: LogEntry[] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + + const failing: Transport = { + name: 'bad', + write() { + throw new Error('sync boom'); + } + }; + const other: Transport = { name: 'good', write: (e) => seenByOther.push(e) }; + + log.addTransport(failing); + log.addTransport(other); + + log.error('t', 'original'); + + // `other` saw both: the failure report (dispatched inside handleFailure, + // which runs before the outer dispatch loop continues) and then the + // original entry. + expect(seenByOther).toHaveLength(2); + + const failureEntry = seenByOther[0]; + const originalEntry = seenByOther[1]; + expect(originalEntry.message).toBe('original'); + expect(failureEntry.category).toBe(LOGGER_NAME); + expect(failureEntry.deniedFor).toEqual(['bad']); + expect(failureEntry.tags).toContain('transport-failure'); + expect(failureEntry.error?.message).toContain('sync boom'); + }); + + it('async rejection triggers the same failure flow', async () => { + const seenByOther: LogEntry[] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + + const failing: Transport = { + name: 'bad-async', + async write() { + throw new Error('async boom'); + } + }; + const other: Transport = { name: 'good', write: (e) => seenByOther.push(e) }; + + log.addTransport(failing); + log.addTransport(other); + + log.error('t', 'original'); + await new Promise((r) => setTimeout(r, 10)); + + // Async case: original is dispatched first (sync loop), then the + // promise rejects and the failure entry is dispatched to `other`. + expect(seenByOther).toHaveLength(2); + expect(seenByOther[0].message).toBe('original'); + expect(seenByOther[1].deniedFor).toEqual(['bad-async']); + expect(seenByOther[1].error?.message).toContain('async boom'); + }); + + it('failed transport does NOT receive the failure report (no loop)', () => { + let throwCount = 0; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + + const failing: Transport = { + name: 'bad', + write() { + throwCount++; + throw new Error('boom'); + } + }; + const other: Transport = { name: 'good', write: () => {} }; + + log.addTransport(failing); + log.addTransport(other); + + log.error('t', 'original'); + + // One throw only — the failed transport did not receive the failure report. + expect(throwCount).toBe(1); + }); + + it('cascade guard: a second-level failure is swallowed', () => { + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport({ + name: 'a', + write() { + throw new Error('a failed'); + } + }); + log.addTransport({ + name: 'b', + write() { + throw new Error('b failed'); + } + }); + + expect(() => log.error('t', 'original')).not.toThrow(); + }); +}); + +describe('callbackTransport', () => { + it('invokes the callback on matching entries', () => { + const hits: LogEntry[] = []; + const cb = callbackTransport((e) => hits.push(e)); + const log = createEngineLogger({ + level: LogLevel.TRACE, + transports: [cb] + }); + log.info('t', 'a'); + expect(hits).toHaveLength(1); + }); + + it('respects minLevel option', () => { + const hits: LogEntry[] = []; + const cb = callbackTransport((e) => hits.push(e), { minLevel: LogLevel.ERROR }); + const log = createEngineLogger({ + level: LogLevel.TRACE, + transports: [cb] + }); + log.warn('t', 'dropped'); + log.error('t', 'kept'); + expect(hits.map((e) => e.message)).toEqual(['kept']); + }); +}); + +describe('createEngineLogger — entry id', () => { + it('auto-generates a unique id per entry', () => { + const { transport, entries } = capture(); + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [transport] }); + log.info('t', 'a'); + log.info('t', 'b'); + expect(typeof entries[0].id).toBe('string'); + expect(entries[0].id.length).toBeGreaterThan(0); + expect(entries[0].id).not.toBe(entries[1].id); + }); + + it('failure entries also carry an id', () => { + const seen: LogEntry[] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport({ name: 'bad', write: () => { throw new Error('x'); } }); + log.addTransport({ name: 'good', write: (e) => seen.push(e) }); + log.error('t', 'msg'); + const failureEntry = seen.find((e) => e.deniedFor !== undefined); + expect(failureEntry?.id).toBeDefined(); + expect(typeof failureEntry?.id).toBe('string'); + }); +}); + +describe('createEngineLogger — buffering', () => { + it('default buffer=0 delivers immediately', () => { + const calls: LogEntry[] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport({ name: 'nobuf', write: (e) => calls.push(e) }); + log.info('t', 'a'); + expect(calls).toHaveLength(1); + }); + + it('holds entries until the buffer fills, then flushes', () => { + const writeCalls: LogEntry[] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport({ + name: 'buf', + buffer: 3, + write: (e) => writeCalls.push(e) + }); + + log.info('t', 'a'); + log.info('t', 'b'); + expect(writeCalls).toHaveLength(0); + + log.info('t', 'c'); // reaches buffer size → flush + expect(writeCalls).toHaveLength(3); + expect(writeCalls.map((e) => e.message)).toEqual(['a', 'b', 'c']); + }); + + it('uses writeBatch when available (single call with all entries)', () => { + const batchCalls: LogEntry[][] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport({ + name: 'batch', + buffer: 3, + write: () => { throw new Error('write should not be called when writeBatch exists'); }, + writeBatch: (entries) => { batchCalls.push(entries); } + }); + + log.info('t', 'a'); + log.info('t', 'b'); + log.info('t', 'c'); + + expect(batchCalls).toHaveLength(1); + expect(batchCalls[0]).toHaveLength(3); + }); + + it('logger.flush() drains everything immediately', () => { + const batchCalls: LogEntry[][] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport({ + name: 'batch', + buffer: 100, + write: () => {}, + writeBatch: (entries) => { batchCalls.push(entries); } + }); + + log.info('t', 'a'); + log.info('t', 'b'); + expect(batchCalls).toHaveLength(0); + + log.flush(); + expect(batchCalls).toHaveLength(1); + expect(batchCalls[0].map((e) => e.message)).toEqual(['a', 'b']); + }); + + it('flushes on transport detach', () => { + const batchCalls: LogEntry[][] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + const remove = log.addTransport({ + name: 'batch', + buffer: 10, + write: () => {}, + writeBatch: (entries) => { batchCalls.push(entries); } + }); + + log.info('t', 'a'); + log.info('t', 'b'); + remove(); + + expect(batchCalls).toHaveLength(1); + expect(batchCalls[0]).toHaveLength(2); + }); + + it('removeAllTransports flushes everything before detach', () => { + const batchCalls: LogEntry[][] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport({ + name: 'batch', + buffer: 10, + write: () => {}, + writeBatch: (entries) => { batchCalls.push(entries); } + }); + + log.info('t', 'a'); + log.removeAllTransports(); + + expect(batchCalls).toHaveLength(1); + }); + + it('time-based flush via flushIntervalMs', async () => { + const batchCalls: LogEntry[][] = []; + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.addTransport({ + name: 'batch', + buffer: 100, + flushIntervalMs: 20, + write: () => {}, + writeBatch: (entries) => { batchCalls.push(entries); } + }); + + log.info('t', 'a'); + expect(batchCalls).toHaveLength(0); + + await new Promise((r) => setTimeout(r, 40)); + expect(batchCalls).toHaveLength(1); + }); +}); + +describe('createEngineLogger — serialize()', () => { + it('produces valid JSON including structured fields', () => { + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + log.info('auth', 'login', { + context: { userId: 1 }, + tags: ['auth'], + traceId: 'abc' + }); + + const json = log.serialize(); + const parsed = JSON.parse(json) as Array<{ + message: string; + context: Record; + tags: string[]; + traceId: string; + timestamp: string; + }>; + + expect(parsed).toHaveLength(1); + expect(parsed[0].message).toBe('login'); + expect(parsed[0].context).toEqual({ userId: 1 }); + expect(parsed[0].tags).toEqual(['auth']); + expect(parsed[0].traceId).toBe('abc'); + expect(typeof parsed[0].timestamp).toBe('string'); + }); + + it('handles circular references gracefully', () => { + const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] }); + const circular: Record = {}; + circular.self = circular; + log.info('t', 'hi', { context: circular }); + expect(() => log.serialize()).not.toThrow(); + }); +}); diff --git a/src/arts/logr/transports.ts b/src/arts/logr/transports.ts new file mode 100644 index 0000000..5d14d21 --- /dev/null +++ b/src/arts/logr/transports.ts @@ -0,0 +1,169 @@ +import type { + Transport, + ConsoleTransportOptions, + HttpTransportOptions, + LogEntry +} from './types.ts'; + +import { LogLevel } from './types.ts'; +import { logger } from './consts.ts'; + +// ============================================================================ +// CONSOLE TRANSPORT +// ============================================================================ + +/** + * Emit log entries to the browser/Node console using the method that matches + * the level: DEBUG → console.debug, INFO → console.info, WARN → console.warn, + * ERROR → console.error. TRACE maps to `console.debug`, FATAL to `console.error` + * since consoles don't expose those levels natively. + * + * @example + * consoleTransport() + * consoleTransport({ timestamp: false, prefix: false }) + * consoleTransport({ minLevel: LogLevel.INFO }) + */ +export function consoleTransport(options: ConsoleTransportOptions = {}): Transport { + const { + timestamp: showTimestamp = true, + prefix: showPrefix = true, + source: showSource = true, + minLevel + } = options; + + return { + name: 'console', + minLevel, + write(entry: LogEntry): void { + const args: unknown[] = []; + + if (showTimestamp) args.push(entry.timestamp.toISOString()); + if (showPrefix) args.push(`[${entry.category}]`); + if (showSource && entry.source?.file) { + const file = entry.source.file; + const afterSlash = file.slice(file.lastIndexOf('/') + 1); + const clean = afterSlash.split('?')[0]; + args.push(`(${clean}:${entry.source.line})`); + } + args.push(entry.message); + + // Structured payload — only appended when it has content. + const meta: Record = {}; + if (entry.context) meta.context = entry.context; + if (entry.tags?.length) meta.tags = entry.tags; + if (entry.traceId) meta.traceId = entry.traceId; + if (entry.durationMs !== undefined) meta.durationMs = entry.durationMs; + if (entry.error) meta.error = entry.error; + if (Object.keys(meta).length > 0) args.push(meta); + + // Browser/Node console has no trace/fatal native — + // TRACE → debug, FATAL → error. + switch (entry.level) { + case LogLevel.TRACE: + 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: + case LogLevel.FATAL: + console.error(...(args as [unknown, ...unknown[]])); + break; + } + } + }; +} + +// ============================================================================ +// HTTP TRANSPORT +// ============================================================================ + +/** + * POST log entries to an HTTP endpoint. Fire-and-forget — network errors are + * reported via `console.error` but do not interrupt the application flow. + * + * No built-in retry or batching. For high-volume needs use a custom transport + * with a queue/retry layer. + */ +export function httpTransport(options: HttpTransportOptions): Transport { + function serialize(entry: LogEntry): Record { + return { + timestamp: entry.timestamp.toISOString(), + level: entry.level, + category: entry.category, + message: entry.message, + context: entry.context, + error: entry.error, + tags: entry.tags, + traceId: entry.traceId, + durationMs: entry.durationMs, + source: entry.source + }; + } + + async function post(payload: unknown): Promise { + const res = await fetch(options.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...options.headers + }, + body: JSON.stringify(payload) + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`http transport push failed: ${res.status} ${res.statusText} ${body}`); + } + } + + return { + name: 'http', + minLevel: options.minLevel ?? LogLevel.ERROR, + async write(entry: LogEntry): Promise { + await post(serialize(entry)); + }, + async writeBatch(entries: LogEntry[]): Promise { + // Batched delivery — single request with an array of entries. + await post(entries.map(serialize)); + } + }; +} + +// ============================================================================ +// CALLBACK TRANSPORT +// ============================================================================ + +/** + * Transport that invokes a callback for each reaching entry. Structurally + * equivalent to `logger.subscribe(fn)` — it exists as a factory so it can be + * pre-registered via `LoggerOptions.transports` or composed with + * `minLevel`/`filter`/`name`. + * + * @example + * callbackTransport((entry) => captured.push(entry)) + * + * @example + * // Sentry integration: ERROR+ only, using the caller-provided SDK + * callbackTransport( + * (entry) => { + * if (entry.error) Sentry.captureException(entry.error); + * else Sentry.captureMessage(entry.message); + * }, + * { minLevel: LogLevel.ERROR, name: 'sentry' } + * ) + */ +export function callbackTransport( + fn: (entry: LogEntry) => void, + options: { minLevel?: LogLevel; filter?: (entry: LogEntry) => boolean; name?: string } = {} +): Transport { + return { + write: fn, + minLevel: options.minLevel, + filter: options.filter, + name: options.name ?? 'callback' + }; +} diff --git a/src/arts/logr/types.ts b/src/arts/logr/types.ts new file mode 100644 index 0000000..d7aa87d --- /dev/null +++ b/src/arts/logr/types.ts @@ -0,0 +1,302 @@ +// ============================================================================ +// LOG LEVEL +// ============================================================================ + +/** + * Severity levels, ordered low-to-high. A logger configured with level X + * processes only entries whose level is >= X. + * + * Aligned with pino, Log4j, OpenTelemetry and Sentry's `Severity` enum. + * + * - **TRACE** — very verbose tracing (spans, frame-by-frame). + * - **DEBUG** — development diagnostics. + * - **INFO** — normal flow events worth noting. + * - **WARN** — unexpected but non-interrupting situations. + * - **ERROR** — recoverable errors that need attention. + * - **FATAL** — unrecoverable failures (route to immediate paging). + */ +export enum LogLevel { + TRACE = 0, + DEBUG = 1, + INFO = 2, + WARN = 3, + ERROR = 4, + FATAL = 5, + /** Disables every log. Deliberately high so any future level stays below. */ + NONE = 999 +} + +// ============================================================================ +// CORE TYPES +// ============================================================================ + +/** + * Functional domain of a log. Used as `[auth]` prefix in the console and as + * a filter in `getLogs({ category })`. + */ +export type MessageCategory = string; + +/** + * Log message. Accepts a literal string or a thunk — the thunk is only + * invoked if the entry passes the global level filter, avoiding formatting + * cost for dropped logs. + * + * @example + * logger.info('auth', 'Login succeeded'); + * logger.debug('db', () => `Query took ${performance.now() - t0}ms`); + */ +export type LogMessage = string | (() => string); + +/** + * Serializable shape of a captured error. + */ +export interface LogError { + name: string; + message: string; + stack?: string; + cause?: unknown; +} + +/** + * Source location where the log was emitted. Parsed from the native `Error` + * stack. In minified production the names are mangled — disable + * `captureSource` in that case. + */ +export interface LogSource { + file?: string; + line?: number; + column?: number; + function?: string; +} + +/** + * Optional caller-provided data for a single log call. + */ +export interface LogInput { + /** Structured diagnostic context. Merged with `globalContext` on the entry. */ + context?: Record; + /** Captured error. Extracted to `entry.error` as a structured `LogError`. */ + error?: unknown; + /** Free-form tags for filtering — finer than `category`. */ + tags?: string[]; + /** Correlation id to trace async flows (fetch → handler → service). */ + traceId?: string; + /** Duration in ms — auto-populated by `timeEnd()`. */ + durationMs?: number; + /** Manual `source` override — useful for wrappers that re-emit logs. */ + source?: LogSource; +} + +/** + * A single history entry. `message` is already resolved to a string — the + * logger knows nothing about i18n. For translated strings, resolve at the + * call site: `logger.info('auth', lang.t('login.ok'))`. + */ +export interface LogEntry { + /** + * Unique identifier (UUID v4 where available, hex fallback otherwise). + * Survives serialization, enabling backend deduplication, partial-retry + * in batched delivery, and cross-transport correlation. + */ + id: string; + timestamp: Date; + level: LogLevel; + category: MessageCategory; + message: string; + /** Logger `globalContext` merged with `input.context`. */ + context?: Record; + error?: LogError; + tags?: string[]; + traceId?: string; + durationMs?: number; + source?: LogSource; + /** + * Names of transports that were skipped when dispatching this entry. + * Auto-populated when a transport throws: the synthetic failure entry is + * dispatched to the remaining transports with the failed one's name here, + * for audit trail and to prevent loops (e.g. if Sentry fails, don't send + * the Sentry-failure report to Sentry). + */ + deniedFor?: string[]; +} + +/** + * `getLogs()` filters. All optional — combined with AND. + */ +export interface LogFilters { + level?: LogLevel; + minLevel?: LogLevel; + category?: MessageCategory; + since?: Date; + /** Matches when the entry's `tags` includes this value. */ + tag?: string; + traceId?: string; +} + +// ============================================================================ +// TRANSPORTS / SUBSCRIBERS +// ============================================================================ + +/** + * Output destination for log entries. + * + * Can be registered at logger creation (`options.transports`) or dynamically + * via `logger.addTransport()` / `logger.subscribe()`. + * + * Each transport decides whether to process each entry via `minLevel` and + * `filter` — both applied after the logger's global level filter. + */ +export interface Transport { + /** + * Process a single entry. Sync or async. + * + * - If it throws synchronously: caught and a failure entry is dispatched + * to the remaining transports with `deniedFor: [this.name]`. + * - If it returns a rejecting `Promise`: same handler via `.catch`. + * - The logger never awaits — dispatch is sync; async work inside the + * transport is fire-and-forget from its own perspective. + */ + write(entry: LogEntry): void | Promise; + /** + * Optional batch delivery. When the logger flushes a buffered transport + * and `writeBatch` is defined, it is called once with all the queued + * entries (instead of N separate `write` calls). Ideal for HTTP-style + * sinks where one batched request is dramatically cheaper than many. + * + * If undefined, the logger falls back to `write` per entry. + */ + writeBatch?(entries: LogEntry[]): void | Promise; + /** + * Number of entries to buffer before flushing. `0` (default) means + * immediate delivery — every entry triggers a `write` call right away. + * + * When `buffer > 0`, entries are queued per-transport; the queue flushes + * when it reaches `buffer` entries, or when `flushIntervalMs` elapses, + * or when `logger.flush()` is called manually. In the browser, a + * `beforeunload` handler flushes everything before the page is torn down. + * + * @default 0 + */ + buffer?: number; + /** + * Flush the queue every N milliseconds even if `buffer` is not yet full. + * Applies only when `buffer > 0`. @default 0 (no time-based flush). + */ + flushIntervalMs?: number; + /** + * Transport-level minimum severity. If undefined, only the logger's global + * level applies. Useful to send only ERROR+ to Sentry while keeping the + * logger at DEBUG. + */ + minLevel?: LogLevel; + /** + * Arbitrary predicate. If it returns `false`, the entry is skipped. + * Useful to filter by category, tag, traceId, etc. + */ + filter?(entry: LogEntry): boolean; + /** + * Optional identifier for introspection and debugging. + * Does not influence behavior. + */ + name?: string; +} + +/** + * Shorthand form of a subscriber — no filters, just an observer on the stream. + * Wrapped as a `Transport` internally on registration. + */ +export type SubscriberFn = (entry: LogEntry) => void; + +export interface ConsoleTransportOptions { + timestamp?: boolean; + prefix?: boolean; + /** Include the `source` in parentheses. @default true */ + source?: boolean; + minLevel?: LogLevel; +} + +export interface HttpTransportOptions { + url: string; + headers?: Record; + /** @default LogLevel.ERROR */ + minLevel?: LogLevel; +} + +// ============================================================================ +// OPTIONS +// ============================================================================ + +export interface LoggerOptions { + /** @default LogLevel.WARN */ + level?: LogLevel; + /** @default 1000 */ + maxLogs?: number; + /** Transports registered at creation time. More can be added dynamically. */ + transports?: Transport[]; + /** Context merged into every entry. */ + globalContext?: Record; + /** + * Capture `file:line` by parsing `new Error().stack` on every log. + * Cost: ~0.1ms/log. Disable in minified production (names are mangled). + * @default DEV + */ + captureSource?: boolean; +} + +// ============================================================================ +// INSTANCE TYPE +// ============================================================================ + +export type LogFn = ( + category: MessageCategory, + message: LogMessage, + input?: LogInput +) => void; + +/** + * Public `EngineLogger` interface. + * Children created via `child()` share history, transports and level with + * the parent — they only extend the `globalContext`. + */ +export interface EngineLogger { + trace: LogFn; + debug: LogFn; + info: LogFn; + warn: LogFn; + error: LogFn; + fatal: LogFn; + + /** Defensive copy of the history, optionally filtered. */ + getLogs: (filters?: LogFilters) => LogEntry[]; + /** Empty the history. Shared with children. */ + clear: () => void; + /** Export the history as JSON. */ + serialize: () => string; + + setLevel: (level: LogLevel) => void; + setMaxLogs: (max: number) => void; + /** Replace the logger's `globalContext`. Does not affect previously created children. */ + setGlobalContext: (ctx: Record) => void; + + /** Register a transport at runtime. Returns a function that detaches it. */ + addTransport: (transport: Transport) => () => void; + /** Sugar: equivalent to `addTransport({ write: fn })`. */ + subscribe: (fn: SubscriberFn) => () => void; + /** Remove every transport. Handy in tests. */ + removeAllTransports: () => void; + /** Read-only snapshot of active transports. */ + transports: () => readonly Transport[]; + + /** Child logger with extended `globalContext`. Shares history and transports. */ + child: (ctx: Record) => EngineLogger; + + time: (label: string) => void; + timeEnd: (label: string, category?: MessageCategory, message?: LogMessage) => void; + + /** + * Flush every buffered transport synchronously. Use before handing control + * back to the host (page unload, test teardown, app exit) to make sure + * queued entries are delivered. + */ + flush: () => void; +} diff --git a/src/web/routes/+layout.svelte b/src/web/routes/+layout.svelte new file mode 100644 index 0000000..ae9c9d0 --- /dev/null +++ b/src/web/routes/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/src/web/routes/+page.svelte b/src/web/routes/+page.svelte new file mode 100644 index 0000000..f078d89 --- /dev/null +++ b/src/web/routes/+page.svelte @@ -0,0 +1,23 @@ + + + + active + + +
+

active

+

+ → páginas de prueba +

+
+ + diff --git a/src/web/routes/test/+page.svelte b/src/web/routes/test/+page.svelte new file mode 100644 index 0000000..c3615a1 --- /dev/null +++ b/src/web/routes/test/+page.svelte @@ -0,0 +1,53 @@ + + + + Tests — arts + + +
+

Páginas de prueba

+

Demostraciones interactivas de los módulos de src/arts.

+ +
    +
  • + /test/lang — cambio de idioma reactivo (es/en/ar) sobre + distintos elementos UI: títulos, navegación, formularios, plurales, interpolación y alias. +
  • +
  • + /test/logr — logger acoplado a lang: entradas en distintos + niveles, historial con locale capturado al registrar, serialización y cambio de nivel. +
  • +
+
+ + diff --git a/src/web/routes/test/lang/+page.svelte b/src/web/routes/test/lang/+page.svelte new file mode 100644 index 0000000..6a6ff51 --- /dev/null +++ b/src/web/routes/test/lang/+page.svelte @@ -0,0 +1,290 @@ + + + + /test/lang + + +
+
+

{lang.t('meta.title')}

+

{lang.t('meta.subtitle')}

+ +
+ Locale: + {#each LOCALES as { code, label, flag } (code)} + + {/each} +
+
+ +
+

Navegación

+ +
+ +
+

Interpolación

+ +

{lang.t('greet', { name: userName })}

+
+ +
+

Pluralización (Intl.PluralRules)

+ +

{lang.t('cart.itemsInCart', { count: cartCount })}

+

+ En árabe se aplican las 6 formas plurales de Intl.PluralRules: zero, one, two, + few, many, other. +

+
+ +
+

Formulario

+
e.preventDefault()}> + + +
+ + + + + +
+
+
+ +
+

Alias (LangRef)

+

+ actions.acceptAlias es un alias #?actions.confirm: + {lang.t('actions.acceptAlias')} +

+
+ +
+

Mensajes de error

+
+
{lang.t('errors.notFound')}
+
{lang.t('errors.network')}
+
+
+ +
+

Uso directo con ts()

+

+ Record inline: {lang.ts({ es: 'Valor directo', en: 'Direct value', ar: 'قيمة مباشرة' })} +

+

+ LangRef con fallback literal: + {lang.ts('#?no.existe|Fallback text')} +

+
+
+ + diff --git a/src/web/routes/test/lang/schema.ts b/src/web/routes/test/lang/schema.ts new file mode 100644 index 0000000..c63b634 --- /dev/null +++ b/src/web/routes/test/lang/schema.ts @@ -0,0 +1,79 @@ +import { p, type LangNode } from '$lang'; + +export const translations = { + meta: { + title: { + es: 'Demostración del módulo lang', + en: 'lang module demo', + ar: 'عرض توضيحي لوحدة lang' + }, + subtitle: { + es: 'Traducciones reactivas con español, inglés y árabe', + en: 'Reactive translations in Spanish, English and Arabic', + ar: 'ترجمات تفاعلية بالإسبانية والإنجليزية والعربية' + } + }, + nav: { + home: { es: 'Inicio', en: 'Home', ar: 'الرئيسية' }, + products: { es: 'Productos', en: 'Products', ar: 'المنتجات' }, + about: { es: 'Acerca de', en: 'About', ar: 'حول' }, + contact: { es: 'Contacto', en: 'Contact', ar: 'اتصل بنا' } + }, + actions: { + save: { es: 'Guardar', en: 'Save', ar: 'حفظ' }, + cancel: { es: 'Cancelar', en: 'Cancel', ar: 'إلغاء' }, + delete: { es: 'Eliminar', en: 'Delete', ar: 'حذف' }, + confirm: { es: 'Confirmar', en: 'Confirm', ar: 'تأكيد' }, + acceptAlias: '#?actions.confirm' + }, + form: { + name: { es: 'Nombre', en: 'Name', ar: 'الاسم' }, + email: { es: 'Correo electrónico', en: 'Email', ar: 'البريد الإلكتروني' }, + message: { es: 'Mensaje', en: 'Message', ar: 'الرسالة' }, + send: { es: 'Enviar', en: 'Send', ar: 'إرسال' }, + placeholder: { + es: 'Escribe aquí...', + en: 'Type here...', + ar: 'اكتب هنا...' + } + }, + greet: (params: { name: string }) => ({ + es: `¡Hola, ${params.name}! Bienvenido.`, + en: `Hello, ${params.name}! Welcome.`, + ar: `مرحباً، ${params.name}! أهلاً وسهلاً.` + }), + cart: { + itemsInCart: p({ + es: { + one: 'Tienes {{count}} producto en el carrito', + other: 'Tienes {{count}} productos en el carrito' + }, + en: { + one: 'You have {{count}} item in your cart', + other: 'You have {{count}} items in your cart' + }, + ar: { + zero: 'لا توجد منتجات في سلتك', + one: 'لديك منتج واحد في سلتك', + two: 'لديك منتجان في سلتك', + few: 'لديك {{count}} منتجات في سلتك', + many: 'لديك {{count}} منتجاً في سلتك', + other: 'لديك {{count}} منتج في سلتك' + } + }) + }, + errors: { + notFound: { + es: 'Página no encontrada', + en: 'Page not found', + ar: 'الصفحة غير موجودة' + }, + network: { + es: 'Error de conexión', + en: 'Network error', + ar: 'خطأ في الشبكة' + } + } +} satisfies LangNode; + +export type TranslationSchema = typeof translations; diff --git a/src/web/routes/test/logr/+page.svelte b/src/web/routes/test/logr/+page.svelte new file mode 100644 index 0000000..41a2666 --- /dev/null +++ b/src/web/routes/test/logr/+page.svelte @@ -0,0 +1,917 @@ + + + + /test/logr + + +
+
+

Prueba del módulo logr

+

+ Logger profesional, desacoplado de i18n. Transports dinámicos con filtros + per-sink, dispatch sync con soporte async, deniedFor en fallos, + globalContext, child(), time(), mensajes lazy, + source auto-capturado, error estructurado, traceId, + tags. +

+
+ +
+
+

Nivel mínimo

+
+ {#each LEVELS as { name, level } (level)} + + {/each} +
+

Actual: {levelName(currentMinLevel)}

+
+ +
+

Emitir básico

+
+ {#each LEVELS as { name, level, color } (level)} + + {/each} +
+

Cada 3 logs comparten traceId.

+
+ +
+

Features

+
+ + + + +
+
+ +
+

Historial

+
+ + +
+
+
+ +
+

Integración con Sentry

+

+ Pegá la DSN de tu proyecto Sentry (se guarda en sessionStorage). El SDK se + importa lazy para no cargarlo si no se usa. +

+
+ +
+ + {#if sentryStatus === 'ready' && !sentryAttached} + + {/if} + {#if sentryAttached} + + + {/if} +
+ {#if sentryStatus === 'error'} +

Error: {sentryErrorMsg}

+ {/if} + {#if sentryAttached} +

+ ✓ Sentry attached como transport. Ahora cualquier log ≥ ERROR irá al dashboard de Sentry; + INFO y WARN como breadcrumbs del próximo error. +

+ {/if} +
+
+ +
+

Transports activos ({activeTransports.length})

+
+ {#each activeTransports as t, i (i)} +
+ {t.name ?? 'anonymous'} + {#if t.minLevel !== undefined} + minLevel: {levelName(t.minLevel)} + {/if} + {#if t.filter} + filter + {/if} +
+ {/each} +
+ +

Añadir dinámicamente:

+
+ + + + +
+ + {#if attached.length > 0} +

Attached runtime:

+
+ {#each attached as a, i (i)} +
+ {a.name} + +
+ {/each} +
+ {/if} +

+ Emite un log de nivel ≥ ERROR con el sync-fail o async-fail + adjunto y observá cómo aparece una entry [logr] con + deniedFor: ["sync-fail"] — el reporte del fallo NO se vuelve a enviar + al transport que falló. +

+
+ +
+

Estadísticas

+
+
{stats.total}Total
+ {#each stats.byLevel as { name, count, color } (name)} +
+ {count}{name} +
+ {/each} +
+
+ +
+

Filtro por traceId

+ +
+ +
+

Historial ({filtered.length})

+ {#if filtered.length === 0} +

Sin entradas. Usá los botones de arriba.

+ {:else} + + + + + + + + + + + + + + + + + + {#each filtered as entry, i (i)} + + + + + + + + + + + + + + {/each} + +
#NivelCatMensajetraceIdtagsdeniedForsourcedurcontexterror
{i + 1} + l.level === entry.level)?.color} + > + {levelName(entry.level)} + + {entry.category}{entry.message} + {#if entry.traceId} + {entry.traceId} + {:else} + — + {/if} + + {#if entry.tags?.length} + {#each entry.tags as t (t)} + {t} + {/each} + {:else} + — + {/if} + + {#if entry.deniedFor?.length} + {#each entry.deniedFor as d (d)} + {d} + {/each} + {:else} + — + {/if} + + {#if entry.source} + {fileShort(entry.source.file)}:{entry.source.line} + {:else} + — + {/if} + + {#if entry.durationMs !== undefined} + {entry.durationMs.toFixed(1)}ms + {:else} + — + {/if} + + {#if entry.context} + {JSON.stringify(entry.context)} + {:else} + — + {/if} + + {#if entry.error} +
+ {entry.error.name}: {entry.error.message} + {#if entry.error.stack} +
{entry.error.stack}
+ {/if} +
+ {:else} + — + {/if} +
+ {/if} +
+
+ + diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /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 0000000..07ec83a --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,21 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) + }, + kit: { + adapter: adapter(), + files : { + routes: 'src/web/routes', + }, + alias: { + $lang: 'src/arts/lang', + $logr: 'src/arts/logr', + } + } +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "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" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..282a635 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'vitest/config'; +import { playwright } from '@vitest/browser-playwright'; +import { sveltekit } from '@sveltejs/kit/vite'; + +export default defineConfig({ + plugins: [sveltekit()], + 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', + test: { + name: 'server', + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] + } + } + ] + } +});