diff --git a/.gitignore b/.gitignore index 0d9e65f..1a05542 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ dist-ssr *.sw? **/.vitepress/cache/ +coverage/ diff --git a/cli/eslint.config.js b/cli/eslint.config.js index e578bc3..dcabf52 100644 --- a/cli/eslint.config.js +++ b/cli/eslint.config.js @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import ts from "typescript-eslint"; const gitignorePath = fileURLToPath( - new globals.URL("./.gitignore", import.meta.url) + new globalThis.URL("./.gitignore", import.meta.url) ); /** @type {import('eslint').Linter.Config} */ @@ -41,23 +41,7 @@ export default ts.config( }, ], "unicorn/no-null": "off", - "unicorn/prevent-abbreviations": [ - "warn", - { - replacements: { - src: { source: false }, - dir: { direction: false, directory: false }, - docs: { documentation: false, documents: false }, - doc: { document: false }, - props: { properties: false }, - params: { parameters: false }, - param: { parameter: false }, - opts: { options: false }, - args: { arguments: false }, - fn: { function: false }, - }, - }, - ], + "unicorn/prevent-abbreviations": "off", }, }, { diff --git a/docs/plugin-spec.md b/docs/plugin-spec.md new file mode 100644 index 0000000..878bfe6 --- /dev/null +++ b/docs/plugin-spec.md @@ -0,0 +1,335 @@ +# Volt Plugin System Spec + +## Overview + +The plugin system enables extending the framework with custom `data-x-*` attribute bindings. + +Plugins follow the same binding patterns as core bindings (text, html, class, events) but can implement specialized behaviors like persistence, scrolling, and URL synchronization. + +## Design Goals + +### Extensibility + +Plugins can access the full binding context including the DOM element, reactive scope, signal utilities, and cleanup registration. + +### Explicit Opt-In + +Built-in plugins require explicit registration to keep the core bundle minimal. Applications only load the functionality they use. + +### Simplicity + +Plugin API mirrors the internal binding handler signature. Developers who end up familiar with Volt internals can easily create plugins. + +### Consistency + +Plugins should integrate seamlessly with the mount/unmount lifecycle, cleanup system, and reactive primitives. + +## Plugin API + +### Registration + +Plugins are registered using the `registerPlugin()` function: + +```ts +registerPlugin(name: string, handler: PluginHandler): void +``` + +The plugin name becomes the `data-x-*` attribute suffix. For example, registering a plugin named `"tooltip"` enables `data-x-tooltip` attributes. + +### Plugin Handler + +Plugin handlers receive a context object and the attribute value: + +```ts +type PluginHandler = (context: PluginContext, value: string) => void +``` + +The handler should: + +1. Parse the attribute value +2. Set up bindings and subscriptions +3. Register cleanup functions for unmount + +### PluginContext + +The context object provides: + +```ts +interface PluginContext { + element: Element; // The bound DOM element + scope: Scope; // Reactive scope with signals + addCleanup(fn: CleanupFunction): void; // Register cleanup + findSignal(path: string): Signal | undefined; // Locate signals by path + evaluate(expression: string): unknown; // Evaluate expressions +} +``` + +### Example: Custom Tooltip Plugin + +```ts +import { registerPlugin } from 'volt'; + +registerPlugin('tooltip', (context, value) => { + const tooltip = document.createElement('div'); + tooltip.className = 'tooltip'; + tooltip.textContent = context.evaluate(value); + + const show = () => document.body.appendChild(tooltip); + const hide = () => tooltip.remove(); + + context.element.addEventListener('mouseenter', show); + context.element.addEventListener('mouseleave', hide); + + context.addCleanup(() => { + hide(); + context.element.removeEventListener('mouseenter', show); + context.element.removeEventListener('mouseleave', hide); + }); + + const signal = context.findSignal(value); + if (signal) { + const unsubscribe = signal.subscribe((newValue) => { + tooltip.textContent = String(newValue); + }); + context.addCleanup(unsubscribe); + } +}); +``` + +## Built-in Plugins + +Volt.js ships with three built-in plugins that must be explicitly registered. + +### data-x-persist + +Synchronizes signal values with persistent storage (`localStorage`, `sessionStorage`, `IndexedDB`). + +**Syntax:** + +```html + +``` + +**Storage Types:** + +- `local` - localStorage (persistent across sessions) +- `session` - sessionStorage (cleared on tab close) +- `indexeddb` - IndexedDB (large datasets, async) +- Custom adapters via `registerStorageAdapter()` + +**Behavior:** + +1. On mount: Load persisted value into signal (if exists) +2. On signal change: Persist new value to storage +3. On unmount: Clean up storage listeners + +**Examples:** + +```html + +
+ + + + + +
+``` + +**Custom Storage Adapters:** + +```ts +interface StorageAdapter { + get(key: string): Promise | unknown; + set(key: string, value: unknown): Promise | void; + remove(key: string): Promise | void; +} + +registerStorageAdapter('custom', { + async get(key) { /* ... */ }, + async set(key, value) { /* ... */ }, + async remove(key) { /* ... */ } +}); +``` + +### data-x-scroll + +Manages scroll behavior including position restoration, programmatic scrolling, scroll spy, and smooth scrolling. + +**Syntax:** + +```html + +
+ + +
+ + +
+ + +
+``` + +**Behaviors:** + +**Position Restoration:** + +```html +
+ +
+``` + +Saves scroll position to the specified signal and restores on mount. + +**Scroll-To:** + +```html + +
+``` + +Scrolls to element when the specified signal changes to match element's ID or selector. + +**Scroll Spy:** + +```html + +
+
+``` + +Updates signal with boolean visibility state using Intersection Observer. + +**Smooth Scrolling:** + +```html +
+``` + +Enables smooth scrolling with configurable behavior from signal. + +### data-x-url + +Synchronizes signal values with URL parameters and hash-based routing. + +**Syntax:** + +```html + + + + + + + +
+``` + +**Behaviors:** + +**Read URL Parameters:** + +```html + +
+``` + +Reads URL parameter on mount and sets signal value. Signal changes do not update URL. + +**Bidirectional Sync:** + +```html + + +``` + +Changes to signal update URL parameter, changes to URL update signal. Uses History API for clean URLs. + +**Hash Routing:** + +```html + +
+
+``` + +Keeps hash portion of URL in sync with signal. Useful for client-side routing. + +**Notes:** + +- Uses History API (`pushState`/`replaceState`) for param sync +- Listens to `popstate` for browser back/forward +- Debounces URL updates to avoid excessive history entries +- Automatically serializes/deserializes values (strings, numbers, booleans) + +## Implementation + +### Integration + +The binder system checks the plugin registry before falling through to unknown attribute warnings + +### Context + +The binder creates a PluginContext from BindingContext: + +```ts +function createPluginContext(bindingContext: BindingContext): PluginContext { + return { + element: bindingContext.element, + scope: bindingContext.scope, + addCleanup: (fn) => bindingContext.cleanups.push(fn), + findSignal: (path) => findSignalInScope(bindingContext.scope, path), + evaluate: (expr) => evaluate(expr, bindingContext.scope) + }; +} +``` + +### Module Structure + +```sh +src/ + core/ + plugin.ts # Plugin registry and API + binder.ts # Modified to integrate plugins + plugins/ + persist.ts # Persistence plugin + scroll.ts # Scroll behavior plugin + url.ts # URL synchronization plugin + index.ts # Exports registerPlugin and built-in plugins +``` + +## Bundle Size Considerations + +With explicit registration, applications control their bundle size: + +- Core framework: ~15 KB gzipped (no plugins) +- Each plugin: ~1-3 KB gzipped +- Applications import only what they use +- Tree-shaking eliminates unused plugins + +Example bundle breakdown: + +```sh +volt/core : 15 KB +volt/plugins/persist : 2 KB +volt/plugins/scroll : 2.5 KB +volt/plugins/url : 1.5 KB +-------------------------------- +Total (all plugins) : 21 KB +``` + +## Extension Points + +Future plugin capabilities: + +- Lifecycle hooks (beforeMount, afterMount, beforeUnmount) +- Plugin dependencies and composition +- Plugin configuration API +- Async plugin initialization +- Plugin registry diff --git a/eslint.config.js b/eslint.config.js index 9f4239e..489816d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import ts from "typescript-eslint"; const gitignorePath = fileURLToPath( - new globals.URL("./.gitignore", import.meta.url) + new globalThis.URL("./.gitignore", import.meta.url) ); /** @type {import('eslint').Linter.Config} */ @@ -23,7 +23,7 @@ export default ts.config( tsconfigRootDir: import.meta.dirname, }, }, - ignores: ["./cli/**", "eslint.config.js"], + ignores: ["./cli/**", "eslint.config.js", "vite.config.ts"], rules: { "no-undef": "off", "@typescript-eslint/no-unused-vars": [ @@ -41,23 +41,7 @@ export default ts.config( }, ], "unicorn/no-null": "off", - "unicorn/prevent-abbreviations": [ - "warn", - { - replacements: { - src: { source: false }, - dir: { direction: false, directory: false }, - docs: { documentation: false, documents: false }, - doc: { document: false }, - props: { properties: false }, - params: { parameters: false }, - param: { parameter: false }, - opts: { options: false }, - args: { arguments: false }, - fn: { function: false }, - }, - }, - ], + "unicorn/prevent-abbreviations": "off", }, }, { diff --git a/package.json b/package.json index b9d2d25..a24fadd 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@eslint/js": "^9.38.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", + "@vitest/coverage-v8": "3.2.4", "dprint": "^0.50.2", "eslint": "^9.38.0", "eslint-plugin-unicorn": "^61.0.2", @@ -31,5 +32,12 @@ "vitest": "^3.2.4", "vue": "^3.5.22" }, - "pnpm": { "overrides": { "vite": "npm:rolldown-vite@7.1.14" }, "onlyBuiltDependencies": ["dprint"] } + "pnpm": { + "overrides": { + "vite": "npm:rolldown-vite@7.1.14" + }, + "onlyBuiltDependencies": [ + "dprint" + ] + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f240fd4..371359c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 + '@vitest/coverage-v8': + specifier: 3.2.4 + version: 3.2.4(vitest@3.2.4(jsdom@27.0.0(postcss@8.5.6))) dprint: specifier: ^0.50.2 version: 0.50.2 @@ -138,6 +141,10 @@ packages: resolution: {integrity: sha512-H1gYPojO6krWHnUXu/T44DrEun/Wl95PJzMXRcM/szstNQczSbwq6wIFJPI9nyE95tarZfUNU3rgorT+wZ6iCQ==} engines: {node: '>= 14.0.0'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@4.0.5': resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==} @@ -172,6 +179,10 @@ packages: resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -360,9 +371,27 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} @@ -385,6 +414,10 @@ packages: '@oxc-project/types@0.93.0': resolution: {integrity: sha512-yNtwmWZIBtJsMr5TEfoZFDxIWV6OdScOpza/f5YxbqUMJk+j6QX3Cf3jgZShGEFYWQJ5j9mJ6jM0tZHu2J9Yrg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@rolldown/binding-android-arm64@1.0.0-beta.41': resolution: {integrity: sha512-Edflndd9lU7JVhVIvJlZhdCj5DkhYDJPIRn4Dx0RUdfc8asP9xHOI5gMd8MesDDx+BJpdIT/uAmVTearteU/mQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -611,6 +644,15 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -753,6 +795,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -761,6 +807,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -779,6 +829,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.7: + resolution: {integrity: sha512-kr1Hy6YRZBkGQSb6puP+D6FQ59Cx4m0siYhAxygMCAgadiWQ6oxAxQXHOMvJx67SJ63jRoVIIg5eXzUbbct1ww==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -936,12 +989,21 @@ packages: resolution: {integrity: sha512-+0Fzg+17jsMMUouK00/Fara5YtGOuE76EAJINHB8VpkXHd0n00rMXtw/03qorOgz23eo8Y0UpYvNZBJJo3aNtw==} hasBin: true + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.237: resolution: {integrity: sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==} emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -1074,6 +1136,10 @@ packages: focus-trap@7.6.5: resolution: {integrity: sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1087,6 +1153,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -1115,6 +1185,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -1162,6 +1235,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1180,6 +1257,25 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1305,6 +1401,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.2: resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} engines: {node: 20 || >=22} @@ -1316,6 +1415,13 @@ packages: magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} @@ -1359,6 +1465,10 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} @@ -1394,6 +1504,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1409,6 +1522,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1578,6 +1695,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1595,9 +1716,25 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -1627,6 +1764,10 @@ packages: tabbable@6.2.0: resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1813,6 +1954,14 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -1955,6 +2104,11 @@ snapshots: dependencies: '@algolia/client-common': 5.40.1 + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@4.0.5': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -1994,6 +2148,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@bcoe/v8-coverage@1.0.2': {} + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -2163,8 +2319,31 @@ snapshots: '@iconify/types@2.0.0': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/wasm-runtime@1.0.7': dependencies: '@emnapi/core': 1.5.0 @@ -2188,6 +2367,9 @@ snapshots: '@oxc-project/types@0.93.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@rolldown/binding-android-arm64@1.0.0-beta.41': optional: true @@ -2432,6 +2614,25 @@ snapshots: vite: rolldown-vite@7.1.14 vue: 3.5.22(typescript@5.9.3) + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(jsdom@27.0.0(postcss@8.5.6)))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.7 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.19 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(jsdom@27.0.0(postcss@8.5.6)) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.2 @@ -2608,12 +2809,16 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + ansis@4.2.0: {} argparse@2.0.1: {} @@ -2626,6 +2831,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + balanced-match@1.0.2: {} baseline-browser-mapping@2.8.17: {} @@ -2774,10 +2985,16 @@ snapshots: '@dprint/win32-arm64': 0.50.2 '@dprint/win32-x64': 0.50.2 + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.237: {} emoji-regex-xs@1.0.0: {} + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + entities@4.5.0: {} entities@6.0.1: {} @@ -2934,6 +3151,11 @@ snapshots: dependencies: tabbable: 6.2.0 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fsevents@2.3.3: optional: true @@ -2945,6 +3167,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@14.0.0: {} globals@16.4.0: {} @@ -2977,6 +3208,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} http-proxy-agent@7.0.2: @@ -3018,6 +3251,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -3030,6 +3265,33 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -3142,6 +3404,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@11.2.2: {} lz-string@1.5.0: {} @@ -3150,6 +3414,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + mark.js@8.11.1: {} mdast-util-to-hast@13.2.0: @@ -3200,6 +3474,8 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minipass@7.1.2: {} + minisearch@7.2.0: {} mitt@3.0.1: {} @@ -3235,6 +3511,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3247,6 +3525,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + pathe@2.0.3: {} pathval@2.0.1: {} @@ -3382,6 +3665,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} @@ -3392,11 +3677,31 @@ snapshots: std-env@3.10.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -3421,6 +3726,12 @@ snapshots: tabbable@6.2.0: {} + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -3675,6 +3986,18 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + ws@8.18.3: {} xml-name-validator@5.0.0: {} diff --git a/src/core/binder.ts b/src/core/binder.ts index 66a7353..a3f4126 100644 --- a/src/core/binder.ts +++ b/src/core/binder.ts @@ -2,23 +2,10 @@ * Binder system for mounting and managing Volt.js bindings */ +import type { BindingContext, CleanupFunction, PluginContext, Scope, Signal } from "../types/volt"; import { getVoltAttributes, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; -import { evaluate, type Scope } from "./evaluator"; -import type { Signal } from "./signal"; - -/** - * Cleanup function returned by binding handlers - */ -type CleanupFunction = () => void; - -/** - * Context object available to all bindings - */ -interface BindingContext { - element: Element; - scope: Scope; - cleanups: CleanupFunction[]; -} +import { evaluate } from "./evaluator"; +import { getPlugin } from "./plugin"; /** * Mount Volt.js on a root element and its descendants. @@ -84,7 +71,17 @@ function bindAttribute(context: BindingContext, name: string, value: string): vo break; } default: { - console.warn(`Unknown binding: data-x-${name}`); + const plugin = getPlugin(name); + if (plugin) { + const pluginContext = createPluginContext(context); + try { + plugin(pluginContext, value); + } catch (error) { + console.error(`Error in plugin "${name}":`, error); + } + } else { + console.warn(`Unknown binding: data-x-${name}`); + } } } } @@ -232,3 +229,22 @@ function findSignalInScope(scope: Scope, path: string): Signal | undefi return undefined; } + +/** + * Create a plugin context from a binding context. + * Provides the plugin with access to utilities and cleanup registration. + * + * @param bindingContext - Internal binding context + * @returns PluginContext for the plugin handler + */ +function createPluginContext(bindingContext: BindingContext): PluginContext { + return { + element: bindingContext.element, + scope: bindingContext.scope, + addCleanup: (fn) => { + bindingContext.cleanups.push(fn); + }, + findSignal: (path) => findSignalInScope(bindingContext.scope, path), + evaluate: (expression) => evaluate(expression, bindingContext.scope), + }; +} diff --git a/src/core/evaluator.ts b/src/core/evaluator.ts index 6f17f86..bcb9808 100644 --- a/src/core/evaluator.ts +++ b/src/core/evaluator.ts @@ -2,7 +2,7 @@ * Safe expression evaluation of simple expressions without using eval() for bindings */ -export type Scope = Record; +import type { Scope } from "../types/volt"; /** * Evaluate a simple expression against a scope object. @@ -87,12 +87,10 @@ function resolvePath(path: string, scope: Scope): unknown { * @returns true if the value is a Signal */ function isSignal(value: unknown): value is { get: () => unknown } { - return ( - typeof value === "object" && - value !== null && - "get" in value && - "set" in value && - "subscribe" in value && - typeof value.get === "function" - ); + return (typeof value === "object" + && value !== null + && "get" in value + && "set" in value + && "subscribe" in value + && typeof value.get === "function"); } diff --git a/src/core/plugin.ts b/src/core/plugin.ts new file mode 100644 index 0000000..f9181bf --- /dev/null +++ b/src/core/plugin.ts @@ -0,0 +1,81 @@ +/** + * Plugin system for extending Volt.js with custom bindings + */ + +import type { PluginHandler } from "../types/volt"; + +const pluginRegistry = new Map(); + +/** + * Register a custom plugin with a given name. + * Plugins extend Volt.js with custom data-x-* attribute bindings. + * + * @param name - Plugin name (will be used as data-x-{name}) + * @param handler - Plugin handler function + * + * @example + * registerPlugin('tooltip', (context, value) => { + * const tooltip = document.createElement('div'); + * tooltip.className = 'tooltip'; + * tooltip.textContent = value; + * context.element.addEventListener('mouseenter', () => { + * document.body.appendChild(tooltip); + * }); + * context.element.addEventListener('mouseleave', () => { + * tooltip.remove(); + * }); + * context.addCleanup(() => tooltip.remove()); + * }); + */ +export function registerPlugin(name: string, handler: PluginHandler): void { + if (pluginRegistry.has(name)) { + console.warn(`Plugin "${name}" is already registered. Overwriting.`); + } + pluginRegistry.set(name, handler); +} + +/** + * Get a plugin handler by name. + * + * @param name - Plugin name + * @returns Plugin handler function or undefined + */ +export function getPlugin(name: string): PluginHandler | undefined { + return pluginRegistry.get(name); +} + +/** + * Check if a plugin is registered. + * + * @param name - Plugin name + * @returns true if the plugin is registered + */ +export function hasPlugin(name: string): boolean { + return pluginRegistry.has(name); +} + +/** + * Unregister a plugin by name. + * + * @param name - Plugin name + * @returns true if the plugin was unregistered, false if it wasn't registered + */ +export function unregisterPlugin(name: string): boolean { + return pluginRegistry.delete(name); +} + +/** + * Get all registered plugin names. + * + * @returns Array of registered plugin names + */ +export function getRegisteredPlugins(): string[] { + return [...pluginRegistry.keys()]; +} + +/** + * Clear all registered plugins. + */ +export function clearPlugins(): void { + pluginRegistry.clear(); +} diff --git a/src/core/signal.ts b/src/core/signal.ts index eb56871..077d9be 100644 --- a/src/core/signal.ts +++ b/src/core/signal.ts @@ -1,45 +1,7 @@ -/** - * A reactive primitive that notifies subscribers when its value changes. - */ -export interface Signal { - /** - * Get the current value of the signal. - */ - get(): T; - - /** - * Update the signal's value. - * If the new value differs from the current value, subscribers will be notified. - */ - set(value: T): void; - - /** - * Subscribe to changes in the signal's value. - * The callback is invoked with the new value whenever it changes. - * Returns an unsubscribe function to remove the subscription. - */ - subscribe(callback: (value: T) => void): () => void; -} - -/** - * A computed signal that derives its value from other signals. - */ -export interface ComputedSignal { - /** - * Get the current computed value. - */ - get(): T; - - /** - * Subscribe to changes in the computed value. - * Returns an unsubscribe function to remove the subscription. - */ - subscribe(callback: (value: T) => void): () => void; -} +import type { ComputedSignal, Signal } from "../types/volt"; /** * Creates a new signal with the given initial value. - * Signals are reactive primitives that automatically notify subscribers when changed. * * @param initialValue - The initial value of the signal * @returns A Signal object with get, set, and subscribe methods @@ -148,7 +110,6 @@ export function computed( /** * Creates a side effect that runs when dependencies change. - * Effects run immediately on creation and whenever dependencies update. * * @param effectFunction - Function to run as a side effect * @param dependencies - Array of signals this effect depends on diff --git a/src/index.ts b/src/index.ts index c140bbd..2593769 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,6 @@ */ export { mount } from "./core/binder"; -export { computed, type ComputedSignal, effect, type Signal, signal } from "./core/signal"; +export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "./core/plugin"; +export { computed, effect, signal } from "./core/signal"; +export type { ComputedSignal, PluginContext, PluginHandler, Signal } from "./types/volt"; diff --git a/src/main.ts b/src/main.ts index f7afabe..0e7f097 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,17 @@ -import { computed, effect, mount, signal } from "./index"; +import { computed, effect, mount, registerPlugin, signal } from "./index"; +import { persistPlugin, scrollPlugin, urlPlugin } from "./plugins"; + +registerPlugin("persist", persistPlugin); +registerPlugin("scroll", scrollPlugin); +registerPlugin("url", urlPlugin); const count = signal(0); const message = signal("Welcome to Volt.js!"); const isActive = signal(true); const inputValue = signal(""); +const scrollPos = signal(0); +const section1Visible = signal(false); +const section2Visible = signal(false); const doubled = computed(() => count.get() * 2, [count]); @@ -17,6 +25,9 @@ const scope = { message, isActive, inputValue, + scrollPos, + section1Visible, + section2Visible, classes: signal({ active: true, highlight: false }), increment: () => { count.set(count.get() + 1); diff --git a/src/plugins/index.ts b/src/plugins/index.ts new file mode 100644 index 0000000..159869c --- /dev/null +++ b/src/plugins/index.ts @@ -0,0 +1,9 @@ +/** + * Built-in plugins for Volt.js + * + * All plugins require explicit registration via registerPlugin() + */ + +export { persistPlugin, registerStorageAdapter } from "./persist"; +export { scrollPlugin } from "./scroll"; +export { urlPlugin } from "./url"; diff --git a/src/plugins/persist.ts b/src/plugins/persist.ts new file mode 100644 index 0000000..6f28870 --- /dev/null +++ b/src/plugins/persist.ts @@ -0,0 +1,222 @@ +/* eslint-disable unicorn/prefer-add-event-listener */ +/** + * Persistence plugin for synchronizing signals with storage + * Supports localStorage, sessionStorage, IndexedDB, and custom adapters + */ + +import type { PluginContext, Signal, StorageAdapter } from "../types/volt"; + +/** + * Registry of custom storage adapters + */ +const storageAdapters = new Map(); + +/** + * Register a custom storage adapter. + * + * @param name - Adapter name (used in data-x-persist="signal:name") + * @param adapter - Storage adapter implementation + */ +export function registerStorageAdapter(name: string, adapter: StorageAdapter): void { + storageAdapters.set(name, adapter); +} + +const localStorageAdapter = { + get(key: string) { + const value = localStorage.getItem(key); + if (value === null) return void 0; + try { + return JSON.parse(value); + } catch { + return value; + } + }, + set(key: string, value: unknown) { + localStorage.setItem(key, JSON.stringify(value)); + }, + remove(key: string) { + localStorage.removeItem(key); + }, +} satisfies StorageAdapter; + +const sessionStorageAdapter = { + get(key: string) { + const value = sessionStorage.getItem(key); + if (value === null) return void 0; + try { + return JSON.parse(value); + } catch { + return value; + } + }, + set(key: string, value: unknown) { + sessionStorage.setItem(key, JSON.stringify(value)); + }, + remove(key: string) { + sessionStorage.removeItem(key); + }, +} satisfies StorageAdapter; + +const idbAdapter = { + async get(key: string) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(["voltStore"], "readonly"); + const store = transaction.objectStore("voltStore"); + const request = store.get(key); + + request.onsuccess = () => { + resolve(request.result?.value); + }; + request.onerror = () => { + reject(request.error); + }; + }); + }, + async set(key: string, value: unknown) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(["voltStore"], "readwrite"); + const store = transaction.objectStore("voltStore"); + const request = store.put({ key, value }); + + request.onsuccess = () => { + resolve(); + }; + request.onerror = () => { + reject(request.error); + }; + }); + }, + async remove(key: string) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(["voltStore"], "readwrite"); + const store = transaction.objectStore("voltStore"); + const request = store.delete(key); + + request.onsuccess = () => { + resolve(); + }; + request.onerror = () => { + reject(request.error); + }; + }); + }, +} satisfies StorageAdapter; + +/** + * Open or create the IndexedDB database + */ +let dbPromise: Promise | undefined; +function openDB(): Promise { + if (dbPromise) return dbPromise; + + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open("voltDB", 1); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains("voltStore")) { + db.createObjectStore("voltStore", { keyPath: "key" }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error); + }; + }); + + return dbPromise; +} + +/** + * Get storage adapter by name + */ +function getStorageAdapter(type: string): StorageAdapter | undefined { + switch (type) { + case "local": { + return localStorageAdapter; + } + case "session": { + return sessionStorageAdapter; + } + case "indexeddb": { + return idbAdapter; + } + default: { + return storageAdapters.get(type); + } + } +} + +/** + * Persist plugin handler. + * Synchronizes signal values with persistent storage. + * + * Syntax: data-x-persist="signalPath:storageType" + * Examples: + * - data-x-persist="count:local" + * - data-x-persist="formData:session" + * - data-x-persist="userData:indexeddb" + * - data-x-persist="settings:customAdapter" + */ +export function persistPlugin(context: PluginContext, value: string): void { + const parts = value.split(":"); + if (parts.length !== 2) { + console.error(`Invalid persist binding: "${value}". Expected format: "signalPath:storageType"`); + return; + } + + const [signalPath, storageType] = parts; + const signal = context.findSignal(signalPath.trim()); + + if (!signal) { + console.error(`Signal "${signalPath}" not found in scope for persist binding`); + return; + } + + const adapter = getStorageAdapter(storageType.trim()); + if (!adapter) { + console.error(`Unknown storage type: "${storageType}"`); + return; + } + + const storageKey = `volt:${signalPath.trim()}`; + + try { + const result = adapter.get(storageKey); + if (result instanceof Promise) { + result.then((storedValue) => { + if (storedValue !== undefined) { + (signal as Signal).set(storedValue); + } + }).catch((error) => { + console.error(`Failed to load persisted value for "${signalPath}":`, error); + }); + } else if (result !== undefined) { + (signal as Signal).set(result); + } + } catch (error) { + console.error(`Failed to load persisted value for "${signalPath}":`, error); + } + + const unsubscribe = signal.subscribe((newValue) => { + try { + const result = adapter.set(storageKey, newValue); + if (result instanceof Promise) { + result.catch((error) => { + console.error(`Failed to persist value for "${signalPath}":`, error); + }); + } + } catch (error) { + console.error(`Failed to persist value for "${signalPath}":`, error); + } + }); + + context.addCleanup(unsubscribe); +} diff --git a/src/plugins/scroll.ts b/src/plugins/scroll.ts new file mode 100644 index 0000000..1fca2ea --- /dev/null +++ b/src/plugins/scroll.ts @@ -0,0 +1,163 @@ +/** + * Scroll plugin for managing scroll behavior + * Supports position restoration, scroll-to, scroll spy, and smooth scrolling + */ + +import type { PluginContext, Signal } from "../types/volt"; + +/** + * Scroll plugin handler. + * Manages various scroll-related behaviors. + * + * Syntax: data-x-scroll="mode:signalPath" + * Modes: + * - restore:signalPath - Save/restore scroll position + * - scrollTo:signalPath - Scroll to element when signal changes + * - spy:signalPath - Update signal when element is visible + * - smooth:signalPath - Enable smooth scrolling behavior + */ +export function scrollPlugin(context: PluginContext, value: string): void { + const parts = value.split(":"); + if (parts.length !== 2) { + console.error(`Invalid scroll binding: "${value}". Expected format: "mode:signalPath"`); + return; + } + + const [mode, signalPath] = parts.map((p) => p.trim()); + + switch (mode) { + case "restore": { + handleScrollRestore(context, signalPath); + break; + } + case "scrollTo": { + handleScrollTo(context, signalPath); + break; + } + case "spy": { + handleScrollSpy(context, signalPath); + break; + } + case "smooth": { + handleSmoothScroll(context, signalPath); + break; + } + default: { + console.error(`Unknown scroll mode: "${mode}"`); + } + } +} + +/** + * Save and restore scroll position. + * Saves current scroll position to signal on scroll events. + * Restores scroll position from signal on mount. + */ +function handleScrollRestore(context: PluginContext, signalPath: string): void { + const signal = context.findSignal(signalPath); + if (!signal) { + console.error(`Signal "${signalPath}" not found for scroll restore`); + return; + } + + const element = context.element as HTMLElement; + const savedPosition = signal.get(); + if (typeof savedPosition === "number") { + element.scrollTop = savedPosition; + } + + const savePosition = () => { + (signal as Signal).set(element.scrollTop); + }; + + element.addEventListener("scroll", savePosition, { passive: true }); + + context.addCleanup(() => { + element.removeEventListener("scroll", savePosition); + }); +} + +/** + * Scroll to element when signal value matches element's ID or selector. + * Listens for changes to the target signal and scrolls to this element. + */ +function handleScrollTo(context: PluginContext, signalPath: string): void { + const signal = context.findSignal(signalPath); + if (!signal) { + console.error(`Signal "${signalPath}" not found for scrollTo`); + return; + } + + const element = context.element as HTMLElement; + const elementId = element.id; + + const checkAndScroll = (target: unknown) => { + if (target === elementId || target === `#${elementId}`) { + element.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }; + + checkAndScroll(signal.get()); + + const unsubscribe = signal.subscribe(checkAndScroll); + context.addCleanup(unsubscribe); +} + +/** + * Update signal when element enters or exits viewport. + * Uses Intersection Observer to track visibility. + */ +function handleScrollSpy(context: PluginContext, signalPath: string): void { + const signal = context.findSignal(signalPath); + if (!signal) { + console.error(`Signal "${signalPath}" not found for scroll spy`); + return; + } + + const element = context.element as HTMLElement; + + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (entry.target === element) { + (signal as Signal).set(entry.isIntersecting); + } + } + }, { threshold: 0.1 }); + + observer.observe(element); + + context.addCleanup(() => { + observer.disconnect(); + }); +} + +/** + * Enable smooth scrolling behavior. + * Applies smooth scroll behavior based on signal value. + */ +function handleSmoothScroll(context: PluginContext, signalPath: string): void { + const signal = context.findSignal(signalPath); + if (!signal) { + console.error(`Signal "${signalPath}" not found for smooth scroll`); + return; + } + + const element = context.element as HTMLElement; + + const applyBehavior = (value: unknown) => { + if (value === true || value === "smooth") { + element.style.scrollBehavior = "smooth"; + } else if (value === false || value === "auto") { + element.style.scrollBehavior = "auto"; + } + }; + + applyBehavior(signal.get()); + + const unsubscribe = signal.subscribe(applyBehavior); + + context.addCleanup(() => { + unsubscribe(); + element.style.scrollBehavior = ""; + }); +} diff --git a/src/plugins/url.ts b/src/plugins/url.ts new file mode 100644 index 0000000..4b6a962 --- /dev/null +++ b/src/plugins/url.ts @@ -0,0 +1,216 @@ +/** + * URL plugin for synchronizing signals with URL parameters and hash routing + * Supports one-way read, bidirectional sync, and hash-based routing + */ + +import type { PluginContext, Signal } from "../types/volt"; + +/** + * URL plugin handler. + * Synchronizes signal values with URL parameters and hash. + * + * Syntax: data-x-url="mode:signalPath" + * Modes: + * - read:signalPath - Read URL param into signal on mount (one-way) + * - sync:signalPath - Bidirectional sync between signal and URL param + * - hash:signalPath - Sync with hash portion for routing + */ +export function urlPlugin(context: PluginContext, value: string): void { + const parts = value.split(":"); + if (parts.length !== 2) { + console.error(`Invalid url binding: "${value}". Expected format: "mode:signalPath"`); + return; + } + + const [mode, signalPath] = parts.map((p) => p.trim()); + + switch (mode) { + case "read": { + handleUrlRead(context, signalPath); + break; + } + case "sync": { + handleUrlSync(context, signalPath); + break; + } + case "hash": { + handleHashRouting(context, signalPath); + break; + } + default: { + console.error(`Unknown url mode: "${mode}"`); + } + } +} + +/** + * Read URL parameter into signal on mount (one-way). + * Signal changes do not update URL. + */ +function handleUrlRead(context: PluginContext, signalPath: string): void { + const signal = context.findSignal(signalPath); + if (!signal) { + console.error(`Signal "${signalPath}" not found for url read`); + return; + } + + const params = new URLSearchParams(globalThis.location.search); + const paramValue = params.get(signalPath); + + if (paramValue !== null) { + (signal as Signal).set(deserializeValue(paramValue)); + } +} + +/** + * Bidirectional sync between signal and URL parameter. + * Changes to either the signal or URL update the other. + */ +function handleUrlSync(context: PluginContext, signalPath: string): void { + const signal = context.findSignal(signalPath); + if (!signal) { + console.error(`Signal "${signalPath}" not found for url sync`); + return; + } + + const params = new URLSearchParams(globalThis.location.search); + const paramValue = params.get(signalPath); + if (paramValue !== null) { + (signal as Signal).set(deserializeValue(paramValue)); + } + + let isUpdatingFromUrl = false; + let updateTimeout: number | undefined; + + const updateUrl = (value: unknown) => { + if (isUpdatingFromUrl) return; + + if (updateTimeout) { + clearTimeout(updateTimeout); + } + + updateTimeout = setTimeout(() => { + const params = new URLSearchParams(globalThis.location.search); + const serialized = serializeValue(value); + + if (serialized === null || serialized === "") { + params.delete(signalPath); + } else { + params.set(signalPath, serialized); + } + + const newSearch = params.toString(); + const newUrl = newSearch ? `?${newSearch}` : globalThis.location.pathname; + + globalThis.history.pushState({}, "", newUrl); + }, 100) as unknown as number; + }; + + const handlePopState = () => { + isUpdatingFromUrl = true; + const params = new URLSearchParams(globalThis.location.search); + const paramValue = params.get(signalPath); + + if (paramValue === null) { + (signal as Signal).set(""); + } else { + (signal as Signal).set(deserializeValue(paramValue)); + } + isUpdatingFromUrl = false; + }; + + const unsubscribe = signal.subscribe(updateUrl); + globalThis.addEventListener("popstate", handlePopState); + + context.addCleanup(() => { + unsubscribe(); + globalThis.removeEventListener("popstate", handlePopState); + if (updateTimeout) { + clearTimeout(updateTimeout); + } + }); +} + +/** + * Sync signal with hash portion of URL for client-side routing. + * Bidirectional sync between signal and window.location.hash. + */ +function handleHashRouting(context: PluginContext, signalPath: string): void { + const signal = context.findSignal(signalPath); + if (!signal) { + console.error(`Signal "${signalPath}" not found for hash routing`); + return; + } + + const currentHash = globalThis.location.hash.slice(1); + if (currentHash) { + (signal as Signal).set(currentHash); + } + + let isUpdatingFromHash = false; + + const updateHash = (value: unknown) => { + if (isUpdatingFromHash) return; + + const hashValue = String(value ?? ""); + const newHash = hashValue ? `#${hashValue}` : ""; + + if (globalThis.location.hash !== newHash) { + globalThis.history.pushState({}, "", newHash || globalThis.location.pathname); + } + }; + + const handleHashChange = () => { + isUpdatingFromHash = true; + const currentHash = globalThis.location.hash.slice(1); + (signal as Signal).set(currentHash); + isUpdatingFromHash = false; + }; + + const unsubscribe = signal.subscribe(updateHash); + globalThis.addEventListener("hashchange", handleHashChange); + + context.addCleanup(() => { + unsubscribe(); + globalThis.removeEventListener("hashchange", handleHashChange); + }); +} + +/** + * Serialize a value for URL parameter storage. + * + * Handles strings, numbers, booleans, and No Value (null/undefined). + */ +function serializeValue(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return JSON.stringify(value); +} + +/** + * Deserialize a URL parameter value by attempting to parse as JSON, falls back to string. + */ +function deserializeValue(value: string): unknown { + if (value === "true") return true; + if (value === "false") return false; + if (value === "null") return null; + if (value === "undefined") return undefined; + + const numberValue = Number(value); + if (!Number.isNaN(numberValue) && value !== "") { + return numberValue; + } + + try { + return JSON.parse(value); + } catch { + return value; + } +} diff --git a/src/types/volt.d.ts b/src/types/volt.d.ts new file mode 100644 index 0000000..f8cd746 --- /dev/null +++ b/src/types/volt.d.ts @@ -0,0 +1,104 @@ +export type CleanupFunction = () => void; + +export type Scope = Record; + +/** + * Context object available to all bindings + */ +export interface BindingContext { + element: Element; + scope: Scope; + cleanups: CleanupFunction[]; +} + +/** + * Context object provided to plugin handlers. + * Contains utilities and references for implementing custom bindings. + */ +export interface PluginContext { + /** + * The DOM element the plugin is bound to + */ + element: Element; + + /** + * The scope object containing signals and data + */ + scope: Scope; + + /** + * Register a cleanup function to be called on unmount. + * Plugins should use this to clean up subscriptions, event listeners, etc. + */ + addCleanup(fn: CleanupFunction): void; + + /** + * Find a signal in the scope by property path. + * Returns undefined if not found or if the value is not a signal. + */ + findSignal(path: string): Signal | undefined; + + /** + * Evaluate an expression against the scope. + * Handles simple property paths, literals, and signal unwrapping. + */ + evaluate(expression: string): unknown; +} + +/** + * Plugin handler function signature. + * Receives context and the attribute value, performs binding setup. + */ +export type PluginHandler = (context: PluginContext, value: string) => void; + +/** + * A reactive primitive that notifies subscribers when its value changes. + */ +export interface Signal { + /** + * Get the current value of the signal. + */ + get(): T; + + /** + * Update the signal's value. + * + * If the new value differs from the current value, subscribers will be notified. + */ + set(value: T): void; + + /** + * Subscribe to changes in the signal's value. + * + * The callback is invoked with the new value whenever it changes. + * + * Returns an unsubscribe function to remove the subscription. + */ + subscribe(callback: (value: T) => void): () => void; +} + +/** + * A computed signal that derives its value from other signals. + */ +export interface ComputedSignal { + /** + * Get the current computed value. + */ + get(): T; + + /** + * Subscribe to changes in the computed value. + * + * Returns an unsubscribe function to remove the subscription. + */ + subscribe(callback: (value: T) => void): () => void; +} + +/** + * Storage adapter interface for custom persistence backends + */ +export interface StorageAdapter { + get(key: string): Promise | unknown; + set(key: string, value: unknown): Promise | void; + remove(key: string): Promise | void; +} diff --git a/test/core/plugin.test.ts b/test/core/plugin.test.ts new file mode 100644 index 0000000..295eb19 --- /dev/null +++ b/test/core/plugin.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "../../src/core/plugin"; + +describe("plugin system", () => { + beforeEach(() => { + clearPlugins(); + }); + + describe("registerPlugin", () => { + it("registers a plugin with a given name", () => { + const handler = vi.fn(); + registerPlugin("test", handler); + + expect(hasPlugin("test")).toBe(true); + }); + + it("allows overwriting existing plugins with a warning", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + registerPlugin("test", handler1); + registerPlugin("test", handler2); + + expect(warnSpy).toHaveBeenCalledWith("Plugin \"test\" is already registered. Overwriting."); + expect(hasPlugin("test")).toBe(true); + + warnSpy.mockRestore(); + }); + + it("registers multiple plugins independently", () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + registerPlugin("plugin1", handler1); + registerPlugin("plugin2", handler2); + + expect(hasPlugin("plugin1")).toBe(true); + expect(hasPlugin("plugin2")).toBe(true); + }); + }); + + describe("hasPlugin", () => { + it("returns true for registered plugins", () => { + const handler = vi.fn(); + registerPlugin("test", handler); + + expect(hasPlugin("test")).toBe(true); + }); + + it("returns false for unregistered plugins", () => { + expect(hasPlugin("nonexistent")).toBe(false); + }); + + it("returns false after plugin is unregistered", () => { + const handler = vi.fn(); + registerPlugin("test", handler); + unregisterPlugin("test"); + + expect(hasPlugin("test")).toBe(false); + }); + }); + + describe("unregisterPlugin", () => { + it("unregisters a plugin and returns true", () => { + const handler = vi.fn(); + registerPlugin("test", handler); + + const result = unregisterPlugin("test"); + + expect(result).toBe(true); + expect(hasPlugin("test")).toBe(false); + }); + + it("returns false when unregistering nonexistent plugin", () => { + const result = unregisterPlugin("nonexistent"); + + expect(result).toBe(false); + }); + }); + + describe("getRegisteredPlugins", () => { + it("returns empty array when no plugins registered", () => { + expect(getRegisteredPlugins()).toEqual([]); + }); + + it("returns array of registered plugin names", () => { + const handler = vi.fn(); + + registerPlugin("plugin1", handler); + registerPlugin("plugin2", handler); + registerPlugin("plugin3", handler); + + const plugins = getRegisteredPlugins(); + + expect(plugins).toHaveLength(3); + expect(plugins).toContain("plugin1"); + expect(plugins).toContain("plugin2"); + expect(plugins).toContain("plugin3"); + }); + + it("updates when plugins are added or removed", () => { + const handler = vi.fn(); + + registerPlugin("plugin1", handler); + expect(getRegisteredPlugins()).toEqual(["plugin1"]); + + registerPlugin("plugin2", handler); + expect(getRegisteredPlugins()).toHaveLength(2); + + unregisterPlugin("plugin1"); + expect(getRegisteredPlugins()).toEqual(["plugin2"]); + }); + }); + + describe("clearPlugins", () => { + it("removes all registered plugins", () => { + const handler = vi.fn(); + + registerPlugin("plugin1", handler); + registerPlugin("plugin2", handler); + registerPlugin("plugin3", handler); + + clearPlugins(); + + expect(getRegisteredPlugins()).toEqual([]); + expect(hasPlugin("plugin1")).toBe(false); + expect(hasPlugin("plugin2")).toBe(false); + expect(hasPlugin("plugin3")).toBe(false); + }); + }); +}); diff --git a/test/integration/plugins.test.ts b/test/integration/plugins.test.ts new file mode 100644 index 0000000..8fd81e9 --- /dev/null +++ b/test/integration/plugins.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mount } from "../../src/core/binder"; +import { clearPlugins, registerPlugin } from "../../src/core/plugin"; +import { signal } from "../../src/core/signal"; + +describe("plugin integration with binder", () => { + beforeEach(() => { + clearPlugins(); + }); + + it("calls registered plugin when binding attribute", () => { + const pluginHandler = vi.fn(); + registerPlugin("custom", pluginHandler); + + const element = document.createElement("div"); + element.dataset.xCustom = "testValue"; + + const scope = { test: "value" }; + mount(element, scope); + + expect(pluginHandler).toHaveBeenCalledOnce(); + expect(pluginHandler).toHaveBeenCalledWith( + expect.objectContaining({ + element, + scope, + addCleanup: expect.any(Function), + findSignal: expect.any(Function), + evaluate: expect.any(Function), + }), + "testValue", + ); + }); + + it("warns when unknown binding is used without plugin", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const element = document.createElement("div"); + element.dataset.xUnknown = "value"; + + mount(element, {}); + + expect(warnSpy).toHaveBeenCalledWith("Unknown binding: data-x-unknown"); + + warnSpy.mockRestore(); + }); + + it("provides working findSignal utility to plugin", () => { + let foundSignal: unknown; + registerPlugin("finder", (context) => { + foundSignal = context.findSignal("count"); + }); + + const element = document.createElement("div"); + element.dataset.xFinder = "test"; + + const count = signal(42); + mount(element, { count }); + + expect(foundSignal).toBe(count); + }); + + it("provides working evaluate utility to plugin", () => { + let evaluatedValue: unknown; + registerPlugin("evaluator", (context, value) => { + evaluatedValue = context.evaluate(value); + }); + + const element = document.createElement("div"); + element.dataset.xEvaluator = "count"; + + const count = signal(100); + mount(element, { count }); + + expect(evaluatedValue).toBe(100); + }); + + it("registers and calls cleanup functions", () => { + const cleanup = vi.fn(); + registerPlugin("cleaner", (context) => { + context.addCleanup(cleanup); + }); + + const element = document.createElement("div"); + element.dataset.xCleaner = "test"; + + const unmount = mount(element, {}); + + expect(cleanup).not.toHaveBeenCalled(); + + unmount(); + + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("handles multiple plugins on same element", () => { + const plugin1 = vi.fn(); + const plugin2 = vi.fn(); + + registerPlugin("plugin1", plugin1); + registerPlugin("plugin2", plugin2); + + const element = document.createElement("div"); + element.dataset.xPlugin1 = "value1"; + element.dataset.xPlugin2 = "value2"; + + mount(element, {}); + + expect(plugin1).toHaveBeenCalledWith(expect.anything(), "value1"); + expect(plugin2).toHaveBeenCalledWith(expect.anything(), "value2"); + }); + + it("allows plugins to work alongside core bindings", () => { + const pluginHandler = vi.fn(); + registerPlugin("custom", pluginHandler); + + const element = document.createElement("div"); + element.dataset.xText = "message"; + element.dataset.xCustom = "customValue"; + + const scope = { message: "Hello" }; + mount(element, scope); + + expect(element.textContent).toBe("Hello"); + expect(pluginHandler).toHaveBeenCalledWith(expect.anything(), "customValue"); + }); + + it("handles plugin errors gracefully", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const badPlugin = vi.fn(() => { + throw new Error("Plugin error"); + }); + + registerPlugin("bad", badPlugin); + + const element = document.createElement("div"); + element.dataset.xBad = "value"; + + mount(element, {}); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Error in plugin \"bad\""), expect.any(Error)); + + errorSpy.mockRestore(); + }); + + it("supports reactive updates from plugins", () => { + registerPlugin("reactive", (context, value) => { + const sig = context.findSignal(value); + if (sig) { + const update = () => { + (context.element as HTMLElement).dataset.testValue = String(sig.get()); + }; + update(); + const unsubscribe = sig.subscribe(update); + context.addCleanup(unsubscribe); + } + }); + + const element = document.createElement("div"); + element.dataset.xReactive = "count"; + + const count = signal(1); + mount(element, { count }); + + expect(element.dataset.testValue).toBe("1"); + + count.set(5); + expect(element.dataset.testValue).toBe("5"); + + count.set(10); + expect(element.dataset.testValue).toBe("10"); + }); +}); diff --git a/test/plugins/persist.test.ts b/test/plugins/persist.test.ts new file mode 100644 index 0000000..ae86483 --- /dev/null +++ b/test/plugins/persist.test.ts @@ -0,0 +1,266 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mount } from "../../src/core/binder"; +import { registerPlugin } from "../../src/core/plugin"; +import { signal } from "../../src/core/signal"; +import { persistPlugin, registerStorageAdapter } from "../../src/plugins/persist"; + +describe("persist plugin", () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + registerPlugin("persist", persistPlugin); + }); + + describe("localStorage persistence", () => { + it("loads persisted value from localStorage on mount", () => { + localStorage.setItem("volt:count", "42"); + + const element = document.createElement("div"); + element.dataset.xPersist = "count:local"; + + const count = signal(0); + mount(element, { count }); + + expect(count.get()).toBe(42); + }); + + it("saves signal value to localStorage on change", async () => { + const element = document.createElement("div"); + element.dataset.xPersist = "count:local"; + + const count = signal(0); + mount(element, { count }); + + count.set(99); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(localStorage.getItem("volt:count")).toBe("99"); + }); + + it("persists string values", async () => { + const element = document.createElement("div"); + element.dataset.xPersist = "name:local"; + + const name = signal("Alice"); + mount(element, { name }); + + name.set("Bob"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(localStorage.getItem("volt:name")).toBe('"Bob"'); + }); + + it("persists object values", async () => { + const element = document.createElement("div"); + element.dataset.xPersist = "user:local"; + + const user = signal({ name: "Alice", age: 30 }); + mount(element, { user }); + + user.set({ name: "Bob", age: 35 }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const stored = localStorage.getItem("volt:user"); + expect(stored).toBe('{"name":"Bob","age":35}'); + }); + + it("does not override signal if localStorage is empty", () => { + const element = document.createElement("div"); + element.dataset.xPersist = "count:local"; + + const count = signal(100); + mount(element, { count }); + + expect(count.get()).toBe(100); + }); + }); + + describe("sessionStorage persistence", () => { + it("loads persisted value from sessionStorage on mount", () => { + sessionStorage.setItem("volt:sessionData", "123"); + + const element = document.createElement("div"); + element.dataset.xPersist = "sessionData:session"; + + const sessionData = signal(0); + mount(element, { sessionData }); + + expect(sessionData.get()).toBe(123); + }); + + it("saves signal value to sessionStorage on change", async () => { + const element = document.createElement("div"); + element.dataset.xPersist = "sessionData:session"; + + const sessionData = signal(0); + mount(element, { sessionData }); + + sessionData.set(456); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(sessionStorage.getItem("volt:sessionData")).toBe("456"); + }); + }); + + describe("custom storage adapters", () => { + it("allows registering custom storage adapter", async () => { + const customStore = new Map(); + registerStorageAdapter("custom", { + get: (key) => customStore.get(key), + set: (key, value) => { + customStore.set(key, value); + }, + remove: (key) => { + customStore.delete(key); + }, + }); + + customStore.set("volt:data", 999); + + const element = document.createElement("div"); + element.dataset.xPersist = "data:custom"; + + const data = signal(0); + mount(element, { data }); + + expect(data.get()).toBe(999); + + data.set(777); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(customStore.get("volt:data")).toBe(777); + }); + + it("supports async custom adapters", async () => { + const customStore = new Map(); + registerStorageAdapter("async", { + get: async (key) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return customStore.get(key); + }, + set: async (key, value) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + customStore.set(key, value); + }, + remove: async (key) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + customStore.delete(key); + }, + }); + + customStore.set("volt:asyncData", 888); + + const element = document.createElement("div"); + element.dataset.xPersist = "asyncData:async"; + + const asyncData = signal(0); + mount(element, { asyncData }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(asyncData.get()).toBe(888); + }); + }); + + describe("error handling", () => { + it("logs error for invalid binding format", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const element = document.createElement("div"); + element.dataset.xPersist = "invalidformat"; + + mount(element, {}); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid persist binding"), + ); + + errorSpy.mockRestore(); + }); + + it("logs error when signal not found", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const element = document.createElement("div"); + element.dataset.xPersist = "nonexistent:local"; + + mount(element, {}); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Signal "nonexistent" not found'), + ); + + errorSpy.mockRestore(); + }); + + it("logs error for unknown storage type", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const element = document.createElement("div"); + element.dataset.xPersist = "data:unknown"; + + const data = signal(0); + mount(element, { data }); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Unknown storage type: "unknown"'), + ); + + errorSpy.mockRestore(); + }); + + it("handles storage adapter errors gracefully", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + registerStorageAdapter("faulty", { + get: () => { + throw new Error("Read error"); + }, + set: () => { + throw new Error("Write error"); + }, + remove: () => {}, + }); + + const element = document.createElement("div"); + element.dataset.xPersist = "data:faulty"; + + const data = signal(0); + mount(element, { data }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(errorSpy).toHaveBeenCalled(); + + data.set(1); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(errorSpy).toHaveBeenCalled(); + + errorSpy.mockRestore(); + }); + }); + + describe("cleanup", () => { + it("stops persisting after unmount", async () => { + const element = document.createElement("div"); + element.dataset.xPersist = "count:local"; + + const count = signal(0); + const cleanup = mount(element, { count }); + + count.set(10); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(localStorage.getItem("volt:count")).toBe("10"); + + cleanup(); + + count.set(20); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(localStorage.getItem("volt:count")).toBe("10"); + }); + }); +}); diff --git a/test/plugins/scroll.test.ts b/test/plugins/scroll.test.ts new file mode 100644 index 0000000..3889b2b --- /dev/null +++ b/test/plugins/scroll.test.ts @@ -0,0 +1,346 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mount } from "../../src/core/binder"; +import { registerPlugin } from "../../src/core/plugin"; +import { signal } from "../../src/core/signal"; +import { scrollPlugin } from "../../src/plugins/scroll"; + +describe("scroll plugin", () => { + beforeEach(() => { + registerPlugin("scroll", scrollPlugin); + }); + + describe("restore mode", () => { + it("restores scroll position from signal on mount", () => { + const element = document.createElement("div"); + element.dataset.xScroll = "restore:scrollPos"; + Object.defineProperty(element, "scrollTop", { + writable: true, + value: 0, + }); + + const scrollPos = signal(250); + mount(element, { scrollPos }); + + expect(element.scrollTop).toBe(250); + }); + + it("saves scroll position to signal on scroll", () => { + const element = document.createElement("div"); + element.dataset.xScroll = "restore:scrollPos"; + + const scrollPos = signal(0); + mount(element, { scrollPos }); + + Object.defineProperty(element, "scrollTop", { + writable: true, + value: 100, + }); + + element.dispatchEvent(new Event("scroll")); + + expect(scrollPos.get()).toBe(100); + }); + + it("does not restore if signal value is not a number", () => { + const element = document.createElement("div"); + element.dataset.xScroll = "restore:scrollPos"; + Object.defineProperty(element, "scrollTop", { + writable: true, + value: 0, + }); + + const scrollPos = signal("not a number" as unknown as number); + mount(element, { scrollPos }); + + expect(element.scrollTop).toBe(0); + }); + + it("cleans up scroll listener on unmount", () => { + const element = document.createElement("div"); + element.dataset.xScroll = "restore:scrollPos"; + + const scrollPos = signal(0); + const cleanup = mount(element, { scrollPos }); + + Object.defineProperty(element, "scrollTop", { + writable: true, + value: 100, + }); + element.dispatchEvent(new Event("scroll")); + expect(scrollPos.get()).toBe(100); + + cleanup(); + + Object.defineProperty(element, "scrollTop", { + writable: true, + value: 200, + }); + element.dispatchEvent(new Event("scroll")); + expect(scrollPos.get()).toBe(100); + }); + }); + + describe("scrollTo mode", () => { + it("scrolls to element when signal matches element ID", () => { + const element = document.createElement("div"); + element.id = "section1"; + element.dataset.xScroll = "scrollTo:targetId"; + + const scrollIntoViewMock = vi.fn(); + element.scrollIntoView = scrollIntoViewMock; + + const targetId = signal(""); + mount(element, { targetId }); + + targetId.set("section1"); + + expect(scrollIntoViewMock).toHaveBeenCalledWith({ + behavior: "smooth", + block: "start", + }); + }); + + it("scrolls to element when signal matches #elementId format", () => { + const element = document.createElement("div"); + element.id = "section2"; + element.dataset.xScroll = "scrollTo:targetId"; + + const scrollIntoViewMock = vi.fn(); + element.scrollIntoView = scrollIntoViewMock; + + const targetId = signal(""); + mount(element, { targetId }); + + targetId.set("#section2"); + + expect(scrollIntoViewMock).toHaveBeenCalledWith({ + behavior: "smooth", + block: "start", + }); + }); + + it("does not scroll if signal does not match element ID", () => { + const element = document.createElement("div"); + element.id = "section1"; + element.dataset.xScroll = "scrollTo:targetId"; + + const scrollIntoViewMock = vi.fn(); + element.scrollIntoView = scrollIntoViewMock; + + const targetId = signal("otherSection"); + mount(element, { targetId }); + + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + }); + + it("scrolls on initial mount if signal already matches", () => { + const element = document.createElement("div"); + element.id = "section1"; + element.dataset.xScroll = "scrollTo:targetId"; + + const scrollIntoViewMock = vi.fn(); + element.scrollIntoView = scrollIntoViewMock; + + const targetId = signal("section1"); + mount(element, { targetId }); + + expect(scrollIntoViewMock).toHaveBeenCalledOnce(); + }); + }); + + describe("spy mode", () => { + it("updates signal when element enters viewport", () => { + const element = document.createElement("div"); + element.dataset.xScroll = "spy:isVisible"; + + const isVisible = signal(false); + + let observerCallback!: IntersectionObserverCallback; + const mockObserver = { + observe: vi.fn(), + disconnect: vi.fn(), + unobserve: vi.fn(), + takeRecords: vi.fn(), + root: null, + rootMargin: "", + thresholds: [], + }; + + (window as typeof globalThis).IntersectionObserver = vi.fn((callback) => { + observerCallback = callback; + return mockObserver; + }) as unknown as typeof IntersectionObserver; + + mount(element, { isVisible }); + + expect(mockObserver.observe).toHaveBeenCalledWith(element); + + observerCallback( + [ + { + isIntersecting: true, + target: element, + } as unknown as IntersectionObserverEntry, + ], + mockObserver as IntersectionObserver, + ); + + expect(isVisible.get()).toBe(true); + + observerCallback( + [ + { + isIntersecting: false, + target: element, + } as unknown as IntersectionObserverEntry, + ], + mockObserver as IntersectionObserver, + ); + + expect(isVisible.get()).toBe(false); + }); + + it("disconnects observer on cleanup", () => { + const element = document.createElement("div"); + element.dataset.xScroll = "spy:isVisible"; + + const isVisible = signal(false); + + const mockObserver = { + observe: vi.fn(), + disconnect: vi.fn(), + unobserve: vi.fn(), + takeRecords: vi.fn(), + root: null, + rootMargin: "", + thresholds: [], + }; + + (window as typeof globalThis).IntersectionObserver = vi.fn(() => { + return mockObserver; + }) as unknown as typeof IntersectionObserver; + + const cleanup = mount(element, { isVisible }); + + cleanup(); + + expect(mockObserver.disconnect).toHaveBeenCalled(); + }); + }); + + describe("smooth mode", () => { + it("applies smooth scroll behavior when signal is true", () => { + const element = document.createElement("div"); + element.dataset.xScroll = "smooth:smoothScroll"; + + const smoothScroll = signal(true); + mount(element, { smoothScroll }); + + expect(element.style.scrollBehavior).toBe("smooth"); + }); + + it("applies smooth scroll behavior when signal is 'smooth'", () => { + const element = document.createElement("div"); + element.dataset.xScroll = "smooth:smoothScroll"; + + const smoothScroll = signal("smooth"); + mount(element, { smoothScroll }); + + expect(element.style.scrollBehavior).toBe("smooth"); + }); + + it("applies auto scroll behavior when signal is false", () => { + const element = document.createElement("div"); + element.dataset.xScroll = "smooth:smoothScroll"; + + const smoothScroll = signal(false); + mount(element, { smoothScroll }); + + expect(element.style.scrollBehavior).toBe("auto"); + }); + + it("applies auto scroll behavior when signal is 'auto'", () => { + const element = document.createElement("div"); + element.dataset.xScroll = "smooth:smoothScroll"; + + const smoothScroll = signal("auto"); + mount(element, { smoothScroll }); + + expect(element.style.scrollBehavior).toBe("auto"); + }); + + it("updates scroll behavior when signal changes", () => { + const element = document.createElement("div"); + element.dataset.xScroll = "smooth:smoothScroll"; + + const smoothScroll = signal(false); + mount(element, { smoothScroll }); + + expect(element.style.scrollBehavior).toBe("auto"); + + smoothScroll.set(true); + expect(element.style.scrollBehavior).toBe("smooth"); + + smoothScroll.set(false); + expect(element.style.scrollBehavior).toBe("auto"); + }); + + it("resets scroll behavior on cleanup", () => { + const element = document.createElement("div"); + element.dataset.xScroll = "smooth:smoothScroll"; + + const smoothScroll = signal(true); + const cleanup = mount(element, { smoothScroll }); + + expect(element.style.scrollBehavior).toBe("smooth"); + + cleanup(); + + expect(element.style.scrollBehavior).toBe(""); + }); + }); + + describe("error handling", () => { + it("logs error for invalid binding format", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const element = document.createElement("div"); + element.dataset.xScroll = "invalidformat"; + + mount(element, {}); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid scroll binding"), + ); + + errorSpy.mockRestore(); + }); + + it("logs error for unknown scroll mode", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const element = document.createElement("div"); + element.dataset.xScroll = "unknown:signal"; + + mount(element, {}); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Unknown scroll mode: "unknown"'), + ); + + errorSpy.mockRestore(); + }); + + it("logs error when signal not found", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const element = document.createElement("div"); + element.dataset.xScroll = "restore:nonexistent"; + + mount(element, {}); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Signal "nonexistent" not found'), + ); + + errorSpy.mockRestore(); + }); + }); +}); diff --git a/test/plugins/url.test.ts b/test/plugins/url.test.ts new file mode 100644 index 0000000..08b0207 --- /dev/null +++ b/test/plugins/url.test.ts @@ -0,0 +1,321 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mount } from "../../src/core/binder"; +import { registerPlugin } from "../../src/core/plugin"; +import { signal } from "../../src/core/signal"; +import { urlPlugin } from "../../src/plugins/url"; + +describe("url plugin", () => { + beforeEach(() => { + registerPlugin("url", urlPlugin); + window.history.replaceState({}, "", "/"); + }); + + describe("read mode", () => { + it("reads URL parameter into signal on mount", () => { + window.history.replaceState({}, "", "/?tab=profile"); + + const element = document.createElement("div"); + element.dataset.xUrl = "read:tab"; + + const tab = signal(""); + mount(element, { tab }); + + expect(tab.get()).toBe("profile"); + }); + + it("does not update URL when signal changes", async () => { + window.history.replaceState({}, "", "/?tab=home"); + + const element = document.createElement("div"); + element.dataset.xUrl = "read:tab"; + + const tab = signal(""); + mount(element, { tab }); + + tab.set("settings"); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(window.location.search).toBe("?tab=home"); + }); + + it("handles missing URL parameter", () => { + window.history.replaceState({}, "", "/"); + + const element = document.createElement("div"); + element.dataset.xUrl = "read:missing"; + + const missing = signal("default"); + mount(element, { missing }); + + expect(missing.get()).toBe("default"); + }); + + it("deserializes boolean values", () => { + window.history.replaceState({}, "", "/?active=true"); + + const element = document.createElement("div"); + element.dataset.xUrl = "read:active"; + + const active = signal(false); + mount(element, { active }); + + expect(active.get()).toBe(true); + }); + + it("deserializes number values", () => { + window.history.replaceState({}, "", "/?count=42"); + + const element = document.createElement("div"); + element.dataset.xUrl = "read:count"; + + const count = signal(0); + mount(element, { count }); + + expect(count.get()).toBe(42); + }); + }); + + describe("sync mode", () => { + it("reads URL parameter into signal on mount", () => { + window.history.replaceState({}, "", "/?filter=active"); + + const element = document.createElement("div"); + element.dataset.xUrl = "sync:filter"; + + const filter = signal(""); + mount(element, { filter }); + + expect(filter.get()).toBe("active"); + }); + + it("updates URL when signal changes", async () => { + window.history.replaceState({}, "", "/"); + + const element = document.createElement("div"); + element.dataset.xUrl = "sync:query"; + + const query = signal(""); + mount(element, { query }); + + query.set("search term"); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(window.location.search).toContain("query=search+term"); + }); + + it("removes parameter from URL when signal is empty", async () => { + window.history.replaceState({}, "", "/?query=test"); + + const element = document.createElement("div"); + element.dataset.xUrl = "sync:query"; + + const query = signal(""); + mount(element, { query }); + + query.set(""); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(window.location.search).toBe(""); + }); + + it("handles popstate events from browser navigation", () => { + window.history.replaceState({}, "", "/?filter=all"); + + const element = document.createElement("div"); + element.dataset.xUrl = "sync:filter"; + + const filter = signal(""); + mount(element, { filter }); + + expect(filter.get()).toBe("all"); + + window.history.replaceState({}, "", "/?filter=completed"); + window.dispatchEvent(new PopStateEvent("popstate")); + + expect(filter.get()).toBe("completed"); + }); + + it("sets signal to empty string when parameter removed from URL", () => { + window.history.replaceState({}, "", "/?filter=test"); + + const element = document.createElement("div"); + element.dataset.xUrl = "sync:filter"; + + const filter = signal(""); + mount(element, { filter }); + + expect(filter.get()).toBe("test"); + + window.history.replaceState({}, "", "/"); + window.dispatchEvent(new PopStateEvent("popstate")); + + expect(filter.get()).toBe(""); + }); + + it("debounces URL updates", async () => { + const pushStateSpy = vi.spyOn(window.history, "pushState"); + + const element = document.createElement("div"); + element.dataset.xUrl = "sync:query"; + + const query = signal(""); + mount(element, { query }); + + query.set("a"); + query.set("ab"); + query.set("abc"); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(pushStateSpy).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(pushStateSpy).toHaveBeenCalledOnce(); + + pushStateSpy.mockRestore(); + }); + + it("cleans up popstate listener on unmount", () => { + window.history.replaceState({}, "", "/?filter=test"); + + const element = document.createElement("div"); + element.dataset.xUrl = "sync:filter"; + + const filter = signal(""); + const cleanup = mount(element, { filter }); + + expect(filter.get()).toBe("test"); + + cleanup(); + + window.history.replaceState({}, "", "/?filter=other"); + window.dispatchEvent(new PopStateEvent("popstate")); + + expect(filter.get()).toBe("test"); + }); + }); + + describe("hash mode", () => { + it("reads hash into signal on mount", () => { + window.location.hash = "#/about"; + + const element = document.createElement("div"); + element.dataset.xUrl = "hash:route"; + + const route = signal(""); + mount(element, { route }); + + expect(route.get()).toBe("/about"); + }); + + it("updates hash when signal changes", () => { + window.location.hash = ""; + + const element = document.createElement("div"); + element.dataset.xUrl = "hash:route"; + + const route = signal(""); + mount(element, { route }); + + route.set("/contact"); + + expect(window.location.hash).toBe("#/contact"); + }); + + it("clears hash when signal is empty", () => { + window.location.hash = "#/page"; + + const element = document.createElement("div"); + element.dataset.xUrl = "hash:route"; + + const route = signal(""); + mount(element, { route }); + + route.set(""); + + expect(window.location.hash).toBe(""); + }); + + it("handles hashchange events", () => { + window.location.hash = "#/home"; + + const element = document.createElement("div"); + element.dataset.xUrl = "hash:route"; + + const route = signal(""); + mount(element, { route }); + + expect(route.get()).toBe("/home"); + + window.location.hash = "#/settings"; + window.dispatchEvent(new Event("hashchange")); + + expect(route.get()).toBe("/settings"); + }); + + it("cleans up hashchange listener on unmount", () => { + window.location.hash = "#/page1"; + + const element = document.createElement("div"); + element.dataset.xUrl = "hash:route"; + + const route = signal(""); + const cleanup = mount(element, { route }); + + expect(route.get()).toBe("/page1"); + + cleanup(); + + window.location.hash = "#/page2"; + window.dispatchEvent(new Event("hashchange")); + + expect(route.get()).toBe("/page1"); + }); + }); + + describe("error handling", () => { + it("logs error for invalid binding format", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const element = document.createElement("div"); + element.dataset.xUrl = "invalidformat"; + + mount(element, {}); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid url binding"), + ); + + errorSpy.mockRestore(); + }); + + it("logs error for unknown url mode", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const element = document.createElement("div"); + element.dataset.xUrl = "unknown:signal"; + + mount(element, {}); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Unknown url mode: "unknown"'), + ); + + errorSpy.mockRestore(); + }); + + it("logs error when signal not found", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const element = document.createElement("div"); + element.dataset.xUrl = "read:nonexistent"; + + mount(element, {}); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Signal "nonexistent" not found'), + ); + + errorSpy.mockRestore(); + }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 8cd94ed..baa68ca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,12 @@ export default defineConfig({ environment: "jsdom", setupFiles: "./test/setupTests.ts", globals: true, - coverage: { provider: "v8", thresholds: { "perFile": true, functions: 50, branches: 50, autoUpdate: true } }, + exclude: ["**/node_modules/**", "**/dist/**", "**/cli/tests/**"], + coverage: { + provider: "v8", + thresholds: { functions: 50, branches: 50 }, + include: ["**/src/**"], + exclude: ["**/cli/src/**"], + }, }, });