a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals

feat: plugin system

+1
.gitignore
··· 24 24 *.sw? 25 25 26 26 **/.vitepress/cache/ 27 + coverage/
+2 -18
cli/eslint.config.js
··· 6 6 import ts from "typescript-eslint"; 7 7 8 8 const gitignorePath = fileURLToPath( 9 - new globals.URL("./.gitignore", import.meta.url) 9 + new globalThis.URL("./.gitignore", import.meta.url) 10 10 ); 11 11 12 12 /** @type {import('eslint').Linter.Config} */ ··· 41 41 }, 42 42 ], 43 43 "unicorn/no-null": "off", 44 - "unicorn/prevent-abbreviations": [ 45 - "warn", 46 - { 47 - replacements: { 48 - src: { source: false }, 49 - dir: { direction: false, directory: false }, 50 - docs: { documentation: false, documents: false }, 51 - doc: { document: false }, 52 - props: { properties: false }, 53 - params: { parameters: false }, 54 - param: { parameter: false }, 55 - opts: { options: false }, 56 - args: { arguments: false }, 57 - fn: { function: false }, 58 - }, 59 - }, 60 - ], 44 + "unicorn/prevent-abbreviations": "off", 61 45 }, 62 46 }, 63 47 {
+335
docs/plugin-spec.md
··· 1 + # Volt Plugin System Spec 2 + 3 + ## Overview 4 + 5 + The plugin system enables extending the framework with custom `data-x-*` attribute bindings. 6 + 7 + Plugins follow the same binding patterns as core bindings (text, html, class, events) but can implement specialized behaviors like persistence, scrolling, and URL synchronization. 8 + 9 + ## Design Goals 10 + 11 + ### Extensibility 12 + 13 + Plugins can access the full binding context including the DOM element, reactive scope, signal utilities, and cleanup registration. 14 + 15 + ### Explicit Opt-In 16 + 17 + Built-in plugins require explicit registration to keep the core bundle minimal. Applications only load the functionality they use. 18 + 19 + ### Simplicity 20 + 21 + Plugin API mirrors the internal binding handler signature. Developers who end up familiar with Volt internals can easily create plugins. 22 + 23 + ### Consistency 24 + 25 + Plugins should integrate seamlessly with the mount/unmount lifecycle, cleanup system, and reactive primitives. 26 + 27 + ## Plugin API 28 + 29 + ### Registration 30 + 31 + Plugins are registered using the `registerPlugin()` function: 32 + 33 + ```ts 34 + registerPlugin(name: string, handler: PluginHandler): void 35 + ``` 36 + 37 + The plugin name becomes the `data-x-*` attribute suffix. For example, registering a plugin named `"tooltip"` enables `data-x-tooltip` attributes. 38 + 39 + ### Plugin Handler 40 + 41 + Plugin handlers receive a context object and the attribute value: 42 + 43 + ```ts 44 + type PluginHandler = (context: PluginContext, value: string) => void 45 + ``` 46 + 47 + The handler should: 48 + 49 + 1. Parse the attribute value 50 + 2. Set up bindings and subscriptions 51 + 3. Register cleanup functions for unmount 52 + 53 + ### PluginContext 54 + 55 + The context object provides: 56 + 57 + ```ts 58 + interface PluginContext { 59 + element: Element; // The bound DOM element 60 + scope: Scope; // Reactive scope with signals 61 + addCleanup(fn: CleanupFunction): void; // Register cleanup 62 + findSignal(path: string): Signal | undefined; // Locate signals by path 63 + evaluate(expression: string): unknown; // Evaluate expressions 64 + } 65 + ``` 66 + 67 + ### Example: Custom Tooltip Plugin 68 + 69 + ```ts 70 + import { registerPlugin } from 'volt'; 71 + 72 + registerPlugin('tooltip', (context, value) => { 73 + const tooltip = document.createElement('div'); 74 + tooltip.className = 'tooltip'; 75 + tooltip.textContent = context.evaluate(value); 76 + 77 + const show = () => document.body.appendChild(tooltip); 78 + const hide = () => tooltip.remove(); 79 + 80 + context.element.addEventListener('mouseenter', show); 81 + context.element.addEventListener('mouseleave', hide); 82 + 83 + context.addCleanup(() => { 84 + hide(); 85 + context.element.removeEventListener('mouseenter', show); 86 + context.element.removeEventListener('mouseleave', hide); 87 + }); 88 + 89 + const signal = context.findSignal(value); 90 + if (signal) { 91 + const unsubscribe = signal.subscribe((newValue) => { 92 + tooltip.textContent = String(newValue); 93 + }); 94 + context.addCleanup(unsubscribe); 95 + } 96 + }); 97 + ``` 98 + 99 + ## Built-in Plugins 100 + 101 + Volt.js ships with three built-in plugins that must be explicitly registered. 102 + 103 + ### data-x-persist 104 + 105 + Synchronizes signal values with persistent storage (`localStorage`, `sessionStorage`, `IndexedDB`). 106 + 107 + **Syntax:** 108 + 109 + ```html 110 + <input data-x-persist="signalName:storageType" /> 111 + ``` 112 + 113 + **Storage Types:** 114 + 115 + - `local` - localStorage (persistent across sessions) 116 + - `session` - sessionStorage (cleared on tab close) 117 + - `indexeddb` - IndexedDB (large datasets, async) 118 + - Custom adapters via `registerStorageAdapter()` 119 + 120 + **Behavior:** 121 + 122 + 1. On mount: Load persisted value into signal (if exists) 123 + 2. On signal change: Persist new value to storage 124 + 3. On unmount: Clean up storage listeners 125 + 126 + **Examples:** 127 + 128 + ```html 129 + <!-- Persist counter to localStorage --> 130 + <div data-x-text="count" data-x-persist="count:local"></div> 131 + 132 + <!-- Persist form state to sessionStorage --> 133 + <input data-x-on-input="updateForm" data-x-persist="formData:session" /> 134 + 135 + <!-- Persist large dataset to IndexedDB --> 136 + <div data-x-persist="userData:indexeddb"></div> 137 + ``` 138 + 139 + **Custom Storage Adapters:** 140 + 141 + ```ts 142 + interface StorageAdapter { 143 + get(key: string): Promise<unknown> | unknown; 144 + set(key: string, value: unknown): Promise<void> | void; 145 + remove(key: string): Promise<void> | void; 146 + } 147 + 148 + registerStorageAdapter('custom', { 149 + async get(key) { /* ... */ }, 150 + async set(key, value) { /* ... */ }, 151 + async remove(key) { /* ... */ } 152 + }); 153 + ``` 154 + 155 + ### data-x-scroll 156 + 157 + Manages scroll behavior including position restoration, programmatic scrolling, scroll spy, and smooth scrolling. 158 + 159 + **Syntax:** 160 + 161 + ```html 162 + <!-- Scroll position restoration --> 163 + <div data-x-scroll="restore:position"></div> 164 + 165 + <!-- Scroll to element when signal changes --> 166 + <div data-x-scroll="scrollTo:targetId"></div> 167 + 168 + <!-- Scroll spy (updates signal when in viewport) --> 169 + <div data-x-scroll="spy:isVisible"></div> 170 + 171 + <!-- Smooth scroll behavior --> 172 + <div data-x-scroll="smooth:true"></div> 173 + ``` 174 + 175 + **Behaviors:** 176 + 177 + **Position Restoration:** 178 + 179 + ```html 180 + <div id="content" data-x-scroll="restore:scrollPos"> 181 + <!-- scroll position saved on scroll, restored on mount --> 182 + </div> 183 + ``` 184 + 185 + Saves scroll position to the specified signal and restores on mount. 186 + 187 + **Scroll-To:** 188 + 189 + ```html 190 + <button data-x-on-click="scrollToSection.set('section2')">Go to Section 2</button> 191 + <div id="section2" data-x-scroll="scrollTo:scrollToSection"></div> 192 + ``` 193 + 194 + Scrolls to element when the specified signal changes to match element's ID or selector. 195 + 196 + **Scroll Spy:** 197 + 198 + ```html 199 + <nav> 200 + <a data-x-class="{ active: section1Visible }">Section 1</a> 201 + <a data-x-class="{ active: section2Visible }">Section 2</a> 202 + </nav> 203 + <div data-x-scroll="spy:section1Visible"></div> 204 + <div data-x-scroll="spy:section2Visible"></div> 205 + ``` 206 + 207 + Updates signal with boolean visibility state using Intersection Observer. 208 + 209 + **Smooth Scrolling:** 210 + 211 + ```html 212 + <div data-x-scroll="smooth:behavior"></div> 213 + ``` 214 + 215 + Enables smooth scrolling with configurable behavior from signal. 216 + 217 + ### data-x-url 218 + 219 + Synchronizes signal values with URL parameters and hash-based routing. 220 + 221 + **Syntax:** 222 + 223 + ```html 224 + <!-- One-way: Read URL param into signal on mount --> 225 + <input data-x-url="read:searchQuery" /> 226 + 227 + <!-- Bidirectional: Keep URL and signal in sync --> 228 + <input data-x-url="sync:filter" /> 229 + 230 + <!-- Hash-based routing --> 231 + <div data-x-url="hash:currentRoute"></div> 232 + ``` 233 + 234 + **Behaviors:** 235 + 236 + **Read URL Parameters:** 237 + 238 + ```html 239 + <!-- Initialize signal from ?tab=profile --> 240 + <div data-x-url="read:tab"></div> 241 + ``` 242 + 243 + Reads URL parameter on mount and sets signal value. Signal changes do not update URL. 244 + 245 + **Bidirectional Sync:** 246 + 247 + ```html 248 + <!-- Keep ?search=query in sync with searchQuery signal --> 249 + <input data-x-on-input="handleSearch" data-x-url="sync:searchQuery" /> 250 + ``` 251 + 252 + Changes to signal update URL parameter, changes to URL update signal. Uses History API for clean URLs. 253 + 254 + **Hash Routing:** 255 + 256 + ```html 257 + <!-- Sync with #/page/about --> 258 + <div data-x-url="hash:route"></div> 259 + <div data-x-text="route === '/page/about' ? 'About Page' : 'Home'"></div> 260 + ``` 261 + 262 + Keeps hash portion of URL in sync with signal. Useful for client-side routing. 263 + 264 + **Notes:** 265 + 266 + - Uses History API (`pushState`/`replaceState`) for param sync 267 + - Listens to `popstate` for browser back/forward 268 + - Debounces URL updates to avoid excessive history entries 269 + - Automatically serializes/deserializes values (strings, numbers, booleans) 270 + 271 + ## Implementation 272 + 273 + ### Integration 274 + 275 + The binder system checks the plugin registry before falling through to unknown attribute warnings 276 + 277 + ### Context 278 + 279 + The binder creates a PluginContext from BindingContext: 280 + 281 + ```ts 282 + function createPluginContext(bindingContext: BindingContext): PluginContext { 283 + return { 284 + element: bindingContext.element, 285 + scope: bindingContext.scope, 286 + addCleanup: (fn) => bindingContext.cleanups.push(fn), 287 + findSignal: (path) => findSignalInScope(bindingContext.scope, path), 288 + evaluate: (expr) => evaluate(expr, bindingContext.scope) 289 + }; 290 + } 291 + ``` 292 + 293 + ### Module Structure 294 + 295 + ```sh 296 + src/ 297 + core/ 298 + plugin.ts # Plugin registry and API 299 + binder.ts # Modified to integrate plugins 300 + plugins/ 301 + persist.ts # Persistence plugin 302 + scroll.ts # Scroll behavior plugin 303 + url.ts # URL synchronization plugin 304 + index.ts # Exports registerPlugin and built-in plugins 305 + ``` 306 + 307 + ## Bundle Size Considerations 308 + 309 + With explicit registration, applications control their bundle size: 310 + 311 + - Core framework: ~15 KB gzipped (no plugins) 312 + - Each plugin: ~1-3 KB gzipped 313 + - Applications import only what they use 314 + - Tree-shaking eliminates unused plugins 315 + 316 + Example bundle breakdown: 317 + 318 + ```sh 319 + volt/core : 15 KB 320 + volt/plugins/persist : 2 KB 321 + volt/plugins/scroll : 2.5 KB 322 + volt/plugins/url : 1.5 KB 323 + -------------------------------- 324 + Total (all plugins) : 21 KB 325 + ``` 326 + 327 + ## Extension Points 328 + 329 + Future plugin capabilities: 330 + 331 + - Lifecycle hooks (beforeMount, afterMount, beforeUnmount) 332 + - Plugin dependencies and composition 333 + - Plugin configuration API 334 + - Async plugin initialization 335 + - Plugin registry
+3 -19
eslint.config.js
··· 6 6 import ts from "typescript-eslint"; 7 7 8 8 const gitignorePath = fileURLToPath( 9 - new globals.URL("./.gitignore", import.meta.url) 9 + new globalThis.URL("./.gitignore", import.meta.url) 10 10 ); 11 11 12 12 /** @type {import('eslint').Linter.Config} */ ··· 23 23 tsconfigRootDir: import.meta.dirname, 24 24 }, 25 25 }, 26 - ignores: ["./cli/**", "eslint.config.js"], 26 + ignores: ["./cli/**", "eslint.config.js", "vite.config.ts"], 27 27 rules: { 28 28 "no-undef": "off", 29 29 "@typescript-eslint/no-unused-vars": [ ··· 41 41 }, 42 42 ], 43 43 "unicorn/no-null": "off", 44 - "unicorn/prevent-abbreviations": [ 45 - "warn", 46 - { 47 - replacements: { 48 - src: { source: false }, 49 - dir: { direction: false, directory: false }, 50 - docs: { documentation: false, documents: false }, 51 - doc: { document: false }, 52 - props: { properties: false }, 53 - params: { parameters: false }, 54 - param: { parameter: false }, 55 - opts: { options: false }, 56 - args: { arguments: false }, 57 - fn: { function: false }, 58 - }, 59 - }, 60 - ], 44 + "unicorn/prevent-abbreviations": "off", 61 45 }, 62 46 }, 63 47 {
+9 -1
package.json
··· 19 19 "@eslint/js": "^9.38.0", 20 20 "@testing-library/dom": "^10.4.1", 21 21 "@testing-library/jest-dom": "^6.9.1", 22 + "@vitest/coverage-v8": "3.2.4", 22 23 "dprint": "^0.50.2", 23 24 "eslint": "^9.38.0", 24 25 "eslint-plugin-unicorn": "^61.0.2", ··· 31 32 "vitest": "^3.2.4", 32 33 "vue": "^3.5.22" 33 34 }, 34 - "pnpm": { "overrides": { "vite": "npm:rolldown-vite@7.1.14" }, "onlyBuiltDependencies": ["dprint"] } 35 + "pnpm": { 36 + "overrides": { 37 + "vite": "npm:rolldown-vite@7.1.14" 38 + }, 39 + "onlyBuiltDependencies": [ 40 + "dprint" 41 + ] 42 + } 35 43 }
+323
pnpm-lock.yaml
··· 23 23 '@testing-library/jest-dom': 24 24 specifier: ^6.9.1 25 25 version: 6.9.1 26 + '@vitest/coverage-v8': 27 + specifier: 3.2.4 28 + version: 3.2.4(vitest@3.2.4(jsdom@27.0.0(postcss@8.5.6))) 26 29 dprint: 27 30 specifier: ^0.50.2 28 31 version: 0.50.2 ··· 138 141 resolution: {integrity: sha512-H1gYPojO6krWHnUXu/T44DrEun/Wl95PJzMXRcM/szstNQczSbwq6wIFJPI9nyE95tarZfUNU3rgorT+wZ6iCQ==} 139 142 engines: {node: '>= 14.0.0'} 140 143 144 + '@ampproject/remapping@2.3.0': 145 + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} 146 + engines: {node: '>=6.0.0'} 147 + 141 148 '@asamuzakjp/css-color@4.0.5': 142 149 resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==} 143 150 ··· 171 178 '@babel/types@7.28.4': 172 179 resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} 173 180 engines: {node: '>=6.9.0'} 181 + 182 + '@bcoe/v8-coverage@1.0.2': 183 + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} 184 + engines: {node: '>=18'} 174 185 175 186 '@csstools/color-helpers@5.1.0': 176 187 resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} ··· 360 371 '@iconify/types@2.0.0': 361 372 resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} 362 373 374 + '@isaacs/cliui@8.0.2': 375 + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} 376 + engines: {node: '>=12'} 377 + 378 + '@istanbuljs/schema@0.1.3': 379 + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} 380 + engines: {node: '>=8'} 381 + 382 + '@jridgewell/gen-mapping@0.3.13': 383 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} 384 + 385 + '@jridgewell/resolve-uri@3.1.2': 386 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} 387 + engines: {node: '>=6.0.0'} 388 + 363 389 '@jridgewell/sourcemap-codec@1.5.5': 364 390 resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} 391 + 392 + '@jridgewell/trace-mapping@0.3.31': 393 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 365 394 366 395 '@napi-rs/wasm-runtime@1.0.7': 367 396 resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} ··· 384 413 385 414 '@oxc-project/types@0.93.0': 386 415 resolution: {integrity: sha512-yNtwmWZIBtJsMr5TEfoZFDxIWV6OdScOpza/f5YxbqUMJk+j6QX3Cf3jgZShGEFYWQJ5j9mJ6jM0tZHu2J9Yrg==} 416 + 417 + '@pkgjs/parseargs@0.11.0': 418 + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} 419 + engines: {node: '>=14'} 387 420 388 421 '@rolldown/binding-android-arm64@1.0.0-beta.41': 389 422 resolution: {integrity: sha512-Edflndd9lU7JVhVIvJlZhdCj5DkhYDJPIRn4Dx0RUdfc8asP9xHOI5gMd8MesDDx+BJpdIT/uAmVTearteU/mQ==} ··· 611 644 vite: ^5.0.0 || ^6.0.0 612 645 vue: ^3.2.25 613 646 647 + '@vitest/coverage-v8@3.2.4': 648 + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} 649 + peerDependencies: 650 + '@vitest/browser': 3.2.4 651 + vitest: 3.2.4 652 + peerDependenciesMeta: 653 + '@vitest/browser': 654 + optional: true 655 + 614 656 '@vitest/expect@3.2.4': 615 657 resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} 616 658 ··· 753 795 resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} 754 796 engines: {node: '>=8'} 755 797 798 + ansi-regex@6.2.2: 799 + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} 800 + engines: {node: '>=12'} 801 + 756 802 ansi-styles@4.3.0: 757 803 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 758 804 engines: {node: '>=8'} ··· 760 806 ansi-styles@5.2.0: 761 807 resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} 762 808 engines: {node: '>=10'} 809 + 810 + ansi-styles@6.2.3: 811 + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} 812 + engines: {node: '>=12'} 763 813 764 814 ansis@4.2.0: 765 815 resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} ··· 779 829 resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} 780 830 engines: {node: '>=12'} 781 831 832 + ast-v8-to-istanbul@0.3.7: 833 + resolution: {integrity: sha512-kr1Hy6YRZBkGQSb6puP+D6FQ59Cx4m0siYhAxygMCAgadiWQ6oxAxQXHOMvJx67SJ63jRoVIIg5eXzUbbct1ww==} 834 + 782 835 balanced-match@1.0.2: 783 836 resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} 784 837 ··· 936 989 resolution: {integrity: sha512-+0Fzg+17jsMMUouK00/Fara5YtGOuE76EAJINHB8VpkXHd0n00rMXtw/03qorOgz23eo8Y0UpYvNZBJJo3aNtw==} 937 990 hasBin: true 938 991 992 + eastasianwidth@0.2.0: 993 + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} 994 + 939 995 electron-to-chromium@1.5.237: 940 996 resolution: {integrity: sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==} 941 997 942 998 emoji-regex-xs@1.0.0: 943 999 resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} 944 1000 1001 + emoji-regex@8.0.0: 1002 + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} 1003 + 1004 + emoji-regex@9.2.2: 1005 + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} 1006 + 945 1007 entities@4.5.0: 946 1008 resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} 947 1009 engines: {node: '>=0.12'} ··· 1074 1136 focus-trap@7.6.5: 1075 1137 resolution: {integrity: sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==} 1076 1138 1139 + foreground-child@3.3.1: 1140 + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} 1141 + engines: {node: '>=14'} 1142 + 1077 1143 fsevents@2.3.3: 1078 1144 resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 1079 1145 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} ··· 1086 1152 glob-parent@6.0.2: 1087 1153 resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} 1088 1154 engines: {node: '>=10.13.0'} 1155 + 1156 + glob@10.4.5: 1157 + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} 1158 + hasBin: true 1089 1159 1090 1160 globals@14.0.0: 1091 1161 resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} ··· 1115 1185 resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} 1116 1186 engines: {node: '>=18'} 1117 1187 1188 + html-escaper@2.0.2: 1189 + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} 1190 + 1118 1191 html-void-elements@3.0.0: 1119 1192 resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} 1120 1193 ··· 1162 1235 resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} 1163 1236 engines: {node: '>=0.10.0'} 1164 1237 1238 + is-fullwidth-code-point@3.0.0: 1239 + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} 1240 + engines: {node: '>=8'} 1241 + 1165 1242 is-glob@4.0.3: 1166 1243 resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} 1167 1244 engines: {node: '>=0.10.0'} ··· 1180 1257 isexe@2.0.0: 1181 1258 resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 1182 1259 1260 + istanbul-lib-coverage@3.2.2: 1261 + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} 1262 + engines: {node: '>=8'} 1263 + 1264 + istanbul-lib-report@3.0.1: 1265 + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} 1266 + engines: {node: '>=10'} 1267 + 1268 + istanbul-lib-source-maps@5.0.6: 1269 + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} 1270 + engines: {node: '>=10'} 1271 + 1272 + istanbul-reports@3.2.0: 1273 + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} 1274 + engines: {node: '>=8'} 1275 + 1276 + jackspeak@3.4.3: 1277 + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} 1278 + 1183 1279 js-tokens@4.0.0: 1184 1280 resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} 1185 1281 ··· 1305 1401 loupe@3.2.1: 1306 1402 resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} 1307 1403 1404 + lru-cache@10.4.3: 1405 + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} 1406 + 1308 1407 lru-cache@11.2.2: 1309 1408 resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} 1310 1409 engines: {node: 20 || >=22} ··· 1316 1415 magic-string@0.30.19: 1317 1416 resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} 1318 1417 1418 + magicast@0.3.5: 1419 + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} 1420 + 1421 + make-dir@4.0.0: 1422 + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} 1423 + engines: {node: '>=10'} 1424 + 1319 1425 mark.js@8.11.1: 1320 1426 resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} 1321 1427 ··· 1357 1463 1358 1464 minimatch@9.0.5: 1359 1465 resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} 1466 + engines: {node: '>=16 || 14 >=14.17'} 1467 + 1468 + minipass@7.1.2: 1469 + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} 1360 1470 engines: {node: '>=16 || 14 >=14.17'} 1361 1471 1362 1472 minisearch@7.2.0: ··· 1394 1504 resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} 1395 1505 engines: {node: '>=10'} 1396 1506 1507 + package-json-from-dist@1.0.1: 1508 + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} 1509 + 1397 1510 parent-module@1.0.1: 1398 1511 resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} 1399 1512 engines: {node: '>=6'} ··· 1408 1521 path-key@3.1.1: 1409 1522 resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} 1410 1523 engines: {node: '>=8'} 1524 + 1525 + path-scurry@1.11.1: 1526 + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} 1527 + engines: {node: '>=16 || 14 >=14.18'} 1411 1528 1412 1529 pathe@2.0.3: 1413 1530 resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} ··· 1578 1695 siginfo@2.0.0: 1579 1696 resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} 1580 1697 1698 + signal-exit@4.1.0: 1699 + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} 1700 + engines: {node: '>=14'} 1701 + 1581 1702 source-map-js@1.2.1: 1582 1703 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 1583 1704 engines: {node: '>=0.10.0'} ··· 1595 1716 std-env@3.10.0: 1596 1717 resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} 1597 1718 1719 + string-width@4.2.3: 1720 + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} 1721 + engines: {node: '>=8'} 1722 + 1723 + string-width@5.1.2: 1724 + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} 1725 + engines: {node: '>=12'} 1726 + 1598 1727 stringify-entities@4.0.4: 1599 1728 resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} 1729 + 1730 + strip-ansi@6.0.1: 1731 + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} 1732 + engines: {node: '>=8'} 1733 + 1734 + strip-ansi@7.1.2: 1735 + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} 1736 + engines: {node: '>=12'} 1600 1737 1601 1738 strip-indent@3.0.0: 1602 1739 resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} ··· 1626 1763 1627 1764 tabbable@6.2.0: 1628 1765 resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} 1766 + 1767 + test-exclude@7.0.1: 1768 + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} 1769 + engines: {node: '>=18'} 1629 1770 1630 1771 tinybench@2.9.0: 1631 1772 resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} ··· 1813 1954 resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} 1814 1955 engines: {node: '>=0.10.0'} 1815 1956 1957 + wrap-ansi@7.0.0: 1958 + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} 1959 + engines: {node: '>=10'} 1960 + 1961 + wrap-ansi@8.1.0: 1962 + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} 1963 + engines: {node: '>=12'} 1964 + 1816 1965 ws@8.18.3: 1817 1966 resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} 1818 1967 engines: {node: '>=10.0.0'} ··· 1955 2104 dependencies: 1956 2105 '@algolia/client-common': 5.40.1 1957 2106 2107 + '@ampproject/remapping@2.3.0': 2108 + dependencies: 2109 + '@jridgewell/gen-mapping': 0.3.13 2110 + '@jridgewell/trace-mapping': 0.3.31 2111 + 1958 2112 '@asamuzakjp/css-color@4.0.5': 1959 2113 dependencies: 1960 2114 '@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) ··· 1993 2147 dependencies: 1994 2148 '@babel/helper-string-parser': 7.27.1 1995 2149 '@babel/helper-validator-identifier': 7.27.1 2150 + 2151 + '@bcoe/v8-coverage@1.0.2': {} 1996 2152 1997 2153 '@csstools/color-helpers@5.1.0': {} 1998 2154 ··· 2163 2319 2164 2320 '@iconify/types@2.0.0': {} 2165 2321 2322 + '@isaacs/cliui@8.0.2': 2323 + dependencies: 2324 + string-width: 5.1.2 2325 + string-width-cjs: string-width@4.2.3 2326 + strip-ansi: 7.1.2 2327 + strip-ansi-cjs: strip-ansi@6.0.1 2328 + wrap-ansi: 8.1.0 2329 + wrap-ansi-cjs: wrap-ansi@7.0.0 2330 + 2331 + '@istanbuljs/schema@0.1.3': {} 2332 + 2333 + '@jridgewell/gen-mapping@0.3.13': 2334 + dependencies: 2335 + '@jridgewell/sourcemap-codec': 1.5.5 2336 + '@jridgewell/trace-mapping': 0.3.31 2337 + 2338 + '@jridgewell/resolve-uri@3.1.2': {} 2339 + 2166 2340 '@jridgewell/sourcemap-codec@1.5.5': {} 2341 + 2342 + '@jridgewell/trace-mapping@0.3.31': 2343 + dependencies: 2344 + '@jridgewell/resolve-uri': 3.1.2 2345 + '@jridgewell/sourcemap-codec': 1.5.5 2167 2346 2168 2347 '@napi-rs/wasm-runtime@1.0.7': 2169 2348 dependencies: ··· 2187 2366 '@oxc-project/runtime@0.92.0': {} 2188 2367 2189 2368 '@oxc-project/types@0.93.0': {} 2369 + 2370 + '@pkgjs/parseargs@0.11.0': 2371 + optional: true 2190 2372 2191 2373 '@rolldown/binding-android-arm64@1.0.0-beta.41': 2192 2374 optional: true ··· 2432 2614 vite: rolldown-vite@7.1.14 2433 2615 vue: 3.5.22(typescript@5.9.3) 2434 2616 2617 + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(jsdom@27.0.0(postcss@8.5.6)))': 2618 + dependencies: 2619 + '@ampproject/remapping': 2.3.0 2620 + '@bcoe/v8-coverage': 1.0.2 2621 + ast-v8-to-istanbul: 0.3.7 2622 + debug: 4.4.3 2623 + istanbul-lib-coverage: 3.2.2 2624 + istanbul-lib-report: 3.0.1 2625 + istanbul-lib-source-maps: 5.0.6 2626 + istanbul-reports: 3.2.0 2627 + magic-string: 0.30.19 2628 + magicast: 0.3.5 2629 + std-env: 3.10.0 2630 + test-exclude: 7.0.1 2631 + tinyrainbow: 2.0.0 2632 + vitest: 3.2.4(jsdom@27.0.0(postcss@8.5.6)) 2633 + transitivePeerDependencies: 2634 + - supports-color 2635 + 2435 2636 '@vitest/expect@3.2.4': 2436 2637 dependencies: 2437 2638 '@types/chai': 5.2.2 ··· 2607 2808 '@algolia/requester-node-http': 5.40.1 2608 2809 2609 2810 ansi-regex@5.0.1: {} 2811 + 2812 + ansi-regex@6.2.2: {} 2610 2813 2611 2814 ansi-styles@4.3.0: 2612 2815 dependencies: ··· 2614 2817 2615 2818 ansi-styles@5.2.0: {} 2616 2819 2820 + ansi-styles@6.2.3: {} 2821 + 2617 2822 ansis@4.2.0: {} 2618 2823 2619 2824 argparse@2.0.1: {} ··· 2625 2830 aria-query@5.3.2: {} 2626 2831 2627 2832 assertion-error@2.0.1: {} 2833 + 2834 + ast-v8-to-istanbul@0.3.7: 2835 + dependencies: 2836 + '@jridgewell/trace-mapping': 0.3.31 2837 + estree-walker: 3.0.3 2838 + js-tokens: 9.0.1 2628 2839 2629 2840 balanced-match@1.0.2: {} 2630 2841 ··· 2773 2984 '@dprint/linux-x64-musl': 0.50.2 2774 2985 '@dprint/win32-arm64': 0.50.2 2775 2986 '@dprint/win32-x64': 0.50.2 2987 + 2988 + eastasianwidth@0.2.0: {} 2776 2989 2777 2990 electron-to-chromium@1.5.237: {} 2778 2991 2779 2992 emoji-regex-xs@1.0.0: {} 2780 2993 2994 + emoji-regex@8.0.0: {} 2995 + 2996 + emoji-regex@9.2.2: {} 2997 + 2781 2998 entities@4.5.0: {} 2782 2999 2783 3000 entities@6.0.1: {} ··· 2934 3151 dependencies: 2935 3152 tabbable: 6.2.0 2936 3153 3154 + foreground-child@3.3.1: 3155 + dependencies: 3156 + cross-spawn: 7.0.6 3157 + signal-exit: 4.1.0 3158 + 2937 3159 fsevents@2.3.3: 2938 3160 optional: true 2939 3161 ··· 2945 3167 dependencies: 2946 3168 is-glob: 4.0.3 2947 3169 3170 + glob@10.4.5: 3171 + dependencies: 3172 + foreground-child: 3.3.1 3173 + jackspeak: 3.4.3 3174 + minimatch: 9.0.5 3175 + minipass: 7.1.2 3176 + package-json-from-dist: 1.0.1 3177 + path-scurry: 1.11.1 3178 + 2948 3179 globals@14.0.0: {} 2949 3180 2950 3181 globals@16.4.0: {} ··· 2976 3207 html-encoding-sniffer@4.0.0: 2977 3208 dependencies: 2978 3209 whatwg-encoding: 3.1.1 3210 + 3211 + html-escaper@2.0.2: {} 2979 3212 2980 3213 html-void-elements@3.0.0: {} 2981 3214 ··· 3018 3251 3019 3252 is-extglob@2.1.1: {} 3020 3253 3254 + is-fullwidth-code-point@3.0.0: {} 3255 + 3021 3256 is-glob@4.0.3: 3022 3257 dependencies: 3023 3258 is-extglob: 2.1.1 ··· 3030 3265 3031 3266 isexe@2.0.0: {} 3032 3267 3268 + istanbul-lib-coverage@3.2.2: {} 3269 + 3270 + istanbul-lib-report@3.0.1: 3271 + dependencies: 3272 + istanbul-lib-coverage: 3.2.2 3273 + make-dir: 4.0.0 3274 + supports-color: 7.2.0 3275 + 3276 + istanbul-lib-source-maps@5.0.6: 3277 + dependencies: 3278 + '@jridgewell/trace-mapping': 0.3.31 3279 + debug: 4.4.3 3280 + istanbul-lib-coverage: 3.2.2 3281 + transitivePeerDependencies: 3282 + - supports-color 3283 + 3284 + istanbul-reports@3.2.0: 3285 + dependencies: 3286 + html-escaper: 2.0.2 3287 + istanbul-lib-report: 3.0.1 3288 + 3289 + jackspeak@3.4.3: 3290 + dependencies: 3291 + '@isaacs/cliui': 8.0.2 3292 + optionalDependencies: 3293 + '@pkgjs/parseargs': 0.11.0 3294 + 3033 3295 js-tokens@4.0.0: {} 3034 3296 3035 3297 js-tokens@9.0.1: {} ··· 3142 3404 3143 3405 loupe@3.2.1: {} 3144 3406 3407 + lru-cache@10.4.3: {} 3408 + 3145 3409 lru-cache@11.2.2: {} 3146 3410 3147 3411 lz-string@1.5.0: {} ··· 3150 3414 dependencies: 3151 3415 '@jridgewell/sourcemap-codec': 1.5.5 3152 3416 3417 + magicast@0.3.5: 3418 + dependencies: 3419 + '@babel/parser': 7.28.4 3420 + '@babel/types': 7.28.4 3421 + source-map-js: 1.2.1 3422 + 3423 + make-dir@4.0.0: 3424 + dependencies: 3425 + semver: 7.7.3 3426 + 3153 3427 mark.js@8.11.1: {} 3154 3428 3155 3429 mdast-util-to-hast@13.2.0: ··· 3200 3474 dependencies: 3201 3475 brace-expansion: 2.0.2 3202 3476 3477 + minipass@7.1.2: {} 3478 + 3203 3479 minisearch@7.2.0: {} 3204 3480 3205 3481 mitt@3.0.1: {} ··· 3234 3510 p-locate@5.0.0: 3235 3511 dependencies: 3236 3512 p-limit: 3.1.0 3513 + 3514 + package-json-from-dist@1.0.1: {} 3237 3515 3238 3516 parent-module@1.0.1: 3239 3517 dependencies: ··· 3246 3524 path-exists@4.0.0: {} 3247 3525 3248 3526 path-key@3.1.1: {} 3527 + 3528 + path-scurry@1.11.1: 3529 + dependencies: 3530 + lru-cache: 10.4.3 3531 + minipass: 7.1.2 3249 3532 3250 3533 pathe@2.0.3: {} 3251 3534 ··· 3382 3665 3383 3666 siginfo@2.0.0: {} 3384 3667 3668 + signal-exit@4.1.0: {} 3669 + 3385 3670 source-map-js@1.2.1: {} 3386 3671 3387 3672 space-separated-tokens@2.0.2: {} ··· 3391 3676 stackback@0.0.2: {} 3392 3677 3393 3678 std-env@3.10.0: {} 3679 + 3680 + string-width@4.2.3: 3681 + dependencies: 3682 + emoji-regex: 8.0.0 3683 + is-fullwidth-code-point: 3.0.0 3684 + strip-ansi: 6.0.1 3685 + 3686 + string-width@5.1.2: 3687 + dependencies: 3688 + eastasianwidth: 0.2.0 3689 + emoji-regex: 9.2.2 3690 + strip-ansi: 7.1.2 3394 3691 3395 3692 stringify-entities@4.0.4: 3396 3693 dependencies: 3397 3694 character-entities-html4: 2.1.0 3398 3695 character-entities-legacy: 3.0.0 3399 3696 3697 + strip-ansi@6.0.1: 3698 + dependencies: 3699 + ansi-regex: 5.0.1 3700 + 3701 + strip-ansi@7.1.2: 3702 + dependencies: 3703 + ansi-regex: 6.2.2 3704 + 3400 3705 strip-indent@3.0.0: 3401 3706 dependencies: 3402 3707 min-indent: 1.0.1 ··· 3420 3725 symbol-tree@3.2.4: {} 3421 3726 3422 3727 tabbable@6.2.0: {} 3728 + 3729 + test-exclude@7.0.1: 3730 + dependencies: 3731 + '@istanbuljs/schema': 0.1.3 3732 + glob: 10.4.5 3733 + minimatch: 9.0.5 3423 3734 3424 3735 tinybench@2.9.0: {} 3425 3736 ··· 3674 3985 stackback: 0.0.2 3675 3986 3676 3987 word-wrap@1.2.5: {} 3988 + 3989 + wrap-ansi@7.0.0: 3990 + dependencies: 3991 + ansi-styles: 4.3.0 3992 + string-width: 4.2.3 3993 + strip-ansi: 6.0.1 3994 + 3995 + wrap-ansi@8.1.0: 3996 + dependencies: 3997 + ansi-styles: 6.2.3 3998 + string-width: 5.1.2 3999 + strip-ansi: 7.1.2 3677 4000 3678 4001 ws@8.18.3: {} 3679 4002
+33 -17
src/core/binder.ts
··· 2 2 * Binder system for mounting and managing Volt.js bindings 3 3 */ 4 4 5 + import type { BindingContext, CleanupFunction, PluginContext, Scope, Signal } from "../types/volt"; 5 6 import { getVoltAttributes, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; 6 - import { evaluate, type Scope } from "./evaluator"; 7 - import type { Signal } from "./signal"; 8 - 9 - /** 10 - * Cleanup function returned by binding handlers 11 - */ 12 - type CleanupFunction = () => void; 13 - 14 - /** 15 - * Context object available to all bindings 16 - */ 17 - interface BindingContext { 18 - element: Element; 19 - scope: Scope; 20 - cleanups: CleanupFunction[]; 21 - } 7 + import { evaluate } from "./evaluator"; 8 + import { getPlugin } from "./plugin"; 22 9 23 10 /** 24 11 * Mount Volt.js on a root element and its descendants. ··· 84 71 break; 85 72 } 86 73 default: { 87 - console.warn(`Unknown binding: data-x-${name}`); 74 + const plugin = getPlugin(name); 75 + if (plugin) { 76 + const pluginContext = createPluginContext(context); 77 + try { 78 + plugin(pluginContext, value); 79 + } catch (error) { 80 + console.error(`Error in plugin "${name}":`, error); 81 + } 82 + } else { 83 + console.warn(`Unknown binding: data-x-${name}`); 84 + } 88 85 } 89 86 } 90 87 } ··· 232 229 233 230 return undefined; 234 231 } 232 + 233 + /** 234 + * Create a plugin context from a binding context. 235 + * Provides the plugin with access to utilities and cleanup registration. 236 + * 237 + * @param bindingContext - Internal binding context 238 + * @returns PluginContext for the plugin handler 239 + */ 240 + function createPluginContext(bindingContext: BindingContext): PluginContext { 241 + return { 242 + element: bindingContext.element, 243 + scope: bindingContext.scope, 244 + addCleanup: (fn) => { 245 + bindingContext.cleanups.push(fn); 246 + }, 247 + findSignal: (path) => findSignalInScope(bindingContext.scope, path), 248 + evaluate: (expression) => evaluate(expression, bindingContext.scope), 249 + }; 250 + }
+7 -9
src/core/evaluator.ts
··· 2 2 * Safe expression evaluation of simple expressions without using eval() for bindings 3 3 */ 4 4 5 - export type Scope = Record<string, unknown>; 5 + import type { Scope } from "../types/volt"; 6 6 7 7 /** 8 8 * Evaluate a simple expression against a scope object. ··· 87 87 * @returns true if the value is a Signal 88 88 */ 89 89 function isSignal(value: unknown): value is { get: () => unknown } { 90 - return ( 91 - typeof value === "object" && 92 - value !== null && 93 - "get" in value && 94 - "set" in value && 95 - "subscribe" in value && 96 - typeof value.get === "function" 97 - ); 90 + return (typeof value === "object" 91 + && value !== null 92 + && "get" in value 93 + && "set" in value 94 + && "subscribe" in value 95 + && typeof value.get === "function"); 98 96 }
+81
src/core/plugin.ts
··· 1 + /** 2 + * Plugin system for extending Volt.js with custom bindings 3 + */ 4 + 5 + import type { PluginHandler } from "../types/volt"; 6 + 7 + const pluginRegistry = new Map<string, PluginHandler>(); 8 + 9 + /** 10 + * Register a custom plugin with a given name. 11 + * Plugins extend Volt.js with custom data-x-* attribute bindings. 12 + * 13 + * @param name - Plugin name (will be used as data-x-{name}) 14 + * @param handler - Plugin handler function 15 + * 16 + * @example 17 + * registerPlugin('tooltip', (context, value) => { 18 + * const tooltip = document.createElement('div'); 19 + * tooltip.className = 'tooltip'; 20 + * tooltip.textContent = value; 21 + * context.element.addEventListener('mouseenter', () => { 22 + * document.body.appendChild(tooltip); 23 + * }); 24 + * context.element.addEventListener('mouseleave', () => { 25 + * tooltip.remove(); 26 + * }); 27 + * context.addCleanup(() => tooltip.remove()); 28 + * }); 29 + */ 30 + export function registerPlugin(name: string, handler: PluginHandler): void { 31 + if (pluginRegistry.has(name)) { 32 + console.warn(`Plugin "${name}" is already registered. Overwriting.`); 33 + } 34 + pluginRegistry.set(name, handler); 35 + } 36 + 37 + /** 38 + * Get a plugin handler by name. 39 + * 40 + * @param name - Plugin name 41 + * @returns Plugin handler function or undefined 42 + */ 43 + export function getPlugin(name: string): PluginHandler | undefined { 44 + return pluginRegistry.get(name); 45 + } 46 + 47 + /** 48 + * Check if a plugin is registered. 49 + * 50 + * @param name - Plugin name 51 + * @returns true if the plugin is registered 52 + */ 53 + export function hasPlugin(name: string): boolean { 54 + return pluginRegistry.has(name); 55 + } 56 + 57 + /** 58 + * Unregister a plugin by name. 59 + * 60 + * @param name - Plugin name 61 + * @returns true if the plugin was unregistered, false if it wasn't registered 62 + */ 63 + export function unregisterPlugin(name: string): boolean { 64 + return pluginRegistry.delete(name); 65 + } 66 + 67 + /** 68 + * Get all registered plugin names. 69 + * 70 + * @returns Array of registered plugin names 71 + */ 72 + export function getRegisteredPlugins(): string[] { 73 + return [...pluginRegistry.keys()]; 74 + } 75 + 76 + /** 77 + * Clear all registered plugins. 78 + */ 79 + export function clearPlugins(): void { 80 + pluginRegistry.clear(); 81 + }
+1 -40
src/core/signal.ts
··· 1 - /** 2 - * A reactive primitive that notifies subscribers when its value changes. 3 - */ 4 - export interface Signal<T> { 5 - /** 6 - * Get the current value of the signal. 7 - */ 8 - get(): T; 9 - 10 - /** 11 - * Update the signal's value. 12 - * If the new value differs from the current value, subscribers will be notified. 13 - */ 14 - set(value: T): void; 15 - 16 - /** 17 - * Subscribe to changes in the signal's value. 18 - * The callback is invoked with the new value whenever it changes. 19 - * Returns an unsubscribe function to remove the subscription. 20 - */ 21 - subscribe(callback: (value: T) => void): () => void; 22 - } 23 - 24 - /** 25 - * A computed signal that derives its value from other signals. 26 - */ 27 - export interface ComputedSignal<T> { 28 - /** 29 - * Get the current computed value. 30 - */ 31 - get(): T; 32 - 33 - /** 34 - * Subscribe to changes in the computed value. 35 - * Returns an unsubscribe function to remove the subscription. 36 - */ 37 - subscribe(callback: (value: T) => void): () => void; 38 - } 1 + import type { ComputedSignal, Signal } from "../types/volt"; 39 2 40 3 /** 41 4 * Creates a new signal with the given initial value. 42 - * Signals are reactive primitives that automatically notify subscribers when changed. 43 5 * 44 6 * @param initialValue - The initial value of the signal 45 7 * @returns A Signal object with get, set, and subscribe methods ··· 148 110 149 111 /** 150 112 * Creates a side effect that runs when dependencies change. 151 - * Effects run immediately on creation and whenever dependencies update. 152 113 * 153 114 * @param effectFunction - Function to run as a side effect 154 115 * @param dependencies - Array of signals this effect depends on
+3 -1
src/index.ts
··· 5 5 */ 6 6 7 7 export { mount } from "./core/binder"; 8 - export { computed, type ComputedSignal, effect, type Signal, signal } from "./core/signal"; 8 + export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "./core/plugin"; 9 + export { computed, effect, signal } from "./core/signal"; 10 + export type { ComputedSignal, PluginContext, PluginHandler, Signal } from "./types/volt";
+12 -1
src/main.ts
··· 1 - import { computed, effect, mount, signal } from "./index"; 1 + import { computed, effect, mount, registerPlugin, signal } from "./index"; 2 + import { persistPlugin, scrollPlugin, urlPlugin } from "./plugins"; 3 + 4 + registerPlugin("persist", persistPlugin); 5 + registerPlugin("scroll", scrollPlugin); 6 + registerPlugin("url", urlPlugin); 2 7 3 8 const count = signal(0); 4 9 const message = signal("Welcome to Volt.js!"); 5 10 const isActive = signal(true); 6 11 const inputValue = signal(""); 12 + const scrollPos = signal(0); 13 + const section1Visible = signal(false); 14 + const section2Visible = signal(false); 7 15 8 16 const doubled = computed(() => count.get() * 2, [count]); 9 17 ··· 17 25 message, 18 26 isActive, 19 27 inputValue, 28 + scrollPos, 29 + section1Visible, 30 + section2Visible, 20 31 classes: signal({ active: true, highlight: false }), 21 32 increment: () => { 22 33 count.set(count.get() + 1);
+9
src/plugins/index.ts
··· 1 + /** 2 + * Built-in plugins for Volt.js 3 + * 4 + * All plugins require explicit registration via registerPlugin() 5 + */ 6 + 7 + export { persistPlugin, registerStorageAdapter } from "./persist"; 8 + export { scrollPlugin } from "./scroll"; 9 + export { urlPlugin } from "./url";
+222
src/plugins/persist.ts
··· 1 + /* eslint-disable unicorn/prefer-add-event-listener */ 2 + /** 3 + * Persistence plugin for synchronizing signals with storage 4 + * Supports localStorage, sessionStorage, IndexedDB, and custom adapters 5 + */ 6 + 7 + import type { PluginContext, Signal, StorageAdapter } from "../types/volt"; 8 + 9 + /** 10 + * Registry of custom storage adapters 11 + */ 12 + const storageAdapters = new Map<string, StorageAdapter>(); 13 + 14 + /** 15 + * Register a custom storage adapter. 16 + * 17 + * @param name - Adapter name (used in data-x-persist="signal:name") 18 + * @param adapter - Storage adapter implementation 19 + */ 20 + export function registerStorageAdapter(name: string, adapter: StorageAdapter): void { 21 + storageAdapters.set(name, adapter); 22 + } 23 + 24 + const localStorageAdapter = { 25 + get(key: string) { 26 + const value = localStorage.getItem(key); 27 + if (value === null) return void 0; 28 + try { 29 + return JSON.parse(value); 30 + } catch { 31 + return value; 32 + } 33 + }, 34 + set(key: string, value: unknown) { 35 + localStorage.setItem(key, JSON.stringify(value)); 36 + }, 37 + remove(key: string) { 38 + localStorage.removeItem(key); 39 + }, 40 + } satisfies StorageAdapter; 41 + 42 + const sessionStorageAdapter = { 43 + get(key: string) { 44 + const value = sessionStorage.getItem(key); 45 + if (value === null) return void 0; 46 + try { 47 + return JSON.parse(value); 48 + } catch { 49 + return value; 50 + } 51 + }, 52 + set(key: string, value: unknown) { 53 + sessionStorage.setItem(key, JSON.stringify(value)); 54 + }, 55 + remove(key: string) { 56 + sessionStorage.removeItem(key); 57 + }, 58 + } satisfies StorageAdapter; 59 + 60 + const idbAdapter = { 61 + async get(key: string) { 62 + const db = await openDB(); 63 + return new Promise((resolve, reject) => { 64 + const transaction = db.transaction(["voltStore"], "readonly"); 65 + const store = transaction.objectStore("voltStore"); 66 + const request = store.get(key); 67 + 68 + request.onsuccess = () => { 69 + resolve(request.result?.value); 70 + }; 71 + request.onerror = () => { 72 + reject(request.error); 73 + }; 74 + }); 75 + }, 76 + async set(key: string, value: unknown) { 77 + const db = await openDB(); 78 + return new Promise<void>((resolve, reject) => { 79 + const transaction = db.transaction(["voltStore"], "readwrite"); 80 + const store = transaction.objectStore("voltStore"); 81 + const request = store.put({ key, value }); 82 + 83 + request.onsuccess = () => { 84 + resolve(); 85 + }; 86 + request.onerror = () => { 87 + reject(request.error); 88 + }; 89 + }); 90 + }, 91 + async remove(key: string) { 92 + const db = await openDB(); 93 + return new Promise<void>((resolve, reject) => { 94 + const transaction = db.transaction(["voltStore"], "readwrite"); 95 + const store = transaction.objectStore("voltStore"); 96 + const request = store.delete(key); 97 + 98 + request.onsuccess = () => { 99 + resolve(); 100 + }; 101 + request.onerror = () => { 102 + reject(request.error); 103 + }; 104 + }); 105 + }, 106 + } satisfies StorageAdapter; 107 + 108 + /** 109 + * Open or create the IndexedDB database 110 + */ 111 + let dbPromise: Promise<IDBDatabase> | undefined; 112 + function openDB(): Promise<IDBDatabase> { 113 + if (dbPromise) return dbPromise; 114 + 115 + dbPromise = new Promise((resolve, reject) => { 116 + const request = indexedDB.open("voltDB", 1); 117 + 118 + request.onupgradeneeded = () => { 119 + const db = request.result; 120 + if (!db.objectStoreNames.contains("voltStore")) { 121 + db.createObjectStore("voltStore", { keyPath: "key" }); 122 + } 123 + }; 124 + 125 + request.onsuccess = () => { 126 + resolve(request.result); 127 + }; 128 + 129 + request.onerror = () => { 130 + reject(request.error); 131 + }; 132 + }); 133 + 134 + return dbPromise; 135 + } 136 + 137 + /** 138 + * Get storage adapter by name 139 + */ 140 + function getStorageAdapter(type: string): StorageAdapter | undefined { 141 + switch (type) { 142 + case "local": { 143 + return localStorageAdapter; 144 + } 145 + case "session": { 146 + return sessionStorageAdapter; 147 + } 148 + case "indexeddb": { 149 + return idbAdapter; 150 + } 151 + default: { 152 + return storageAdapters.get(type); 153 + } 154 + } 155 + } 156 + 157 + /** 158 + * Persist plugin handler. 159 + * Synchronizes signal values with persistent storage. 160 + * 161 + * Syntax: data-x-persist="signalPath:storageType" 162 + * Examples: 163 + * - data-x-persist="count:local" 164 + * - data-x-persist="formData:session" 165 + * - data-x-persist="userData:indexeddb" 166 + * - data-x-persist="settings:customAdapter" 167 + */ 168 + export function persistPlugin(context: PluginContext, value: string): void { 169 + const parts = value.split(":"); 170 + if (parts.length !== 2) { 171 + console.error(`Invalid persist binding: "${value}". Expected format: "signalPath:storageType"`); 172 + return; 173 + } 174 + 175 + const [signalPath, storageType] = parts; 176 + const signal = context.findSignal(signalPath.trim()); 177 + 178 + if (!signal) { 179 + console.error(`Signal "${signalPath}" not found in scope for persist binding`); 180 + return; 181 + } 182 + 183 + const adapter = getStorageAdapter(storageType.trim()); 184 + if (!adapter) { 185 + console.error(`Unknown storage type: "${storageType}"`); 186 + return; 187 + } 188 + 189 + const storageKey = `volt:${signalPath.trim()}`; 190 + 191 + try { 192 + const result = adapter.get(storageKey); 193 + if (result instanceof Promise) { 194 + result.then((storedValue) => { 195 + if (storedValue !== undefined) { 196 + (signal as Signal<unknown>).set(storedValue); 197 + } 198 + }).catch((error) => { 199 + console.error(`Failed to load persisted value for "${signalPath}":`, error); 200 + }); 201 + } else if (result !== undefined) { 202 + (signal as Signal<unknown>).set(result); 203 + } 204 + } catch (error) { 205 + console.error(`Failed to load persisted value for "${signalPath}":`, error); 206 + } 207 + 208 + const unsubscribe = signal.subscribe((newValue) => { 209 + try { 210 + const result = adapter.set(storageKey, newValue); 211 + if (result instanceof Promise) { 212 + result.catch((error) => { 213 + console.error(`Failed to persist value for "${signalPath}":`, error); 214 + }); 215 + } 216 + } catch (error) { 217 + console.error(`Failed to persist value for "${signalPath}":`, error); 218 + } 219 + }); 220 + 221 + context.addCleanup(unsubscribe); 222 + }
+163
src/plugins/scroll.ts
··· 1 + /** 2 + * Scroll plugin for managing scroll behavior 3 + * Supports position restoration, scroll-to, scroll spy, and smooth scrolling 4 + */ 5 + 6 + import type { PluginContext, Signal } from "../types/volt"; 7 + 8 + /** 9 + * Scroll plugin handler. 10 + * Manages various scroll-related behaviors. 11 + * 12 + * Syntax: data-x-scroll="mode:signalPath" 13 + * Modes: 14 + * - restore:signalPath - Save/restore scroll position 15 + * - scrollTo:signalPath - Scroll to element when signal changes 16 + * - spy:signalPath - Update signal when element is visible 17 + * - smooth:signalPath - Enable smooth scrolling behavior 18 + */ 19 + export function scrollPlugin(context: PluginContext, value: string): void { 20 + const parts = value.split(":"); 21 + if (parts.length !== 2) { 22 + console.error(`Invalid scroll binding: "${value}". Expected format: "mode:signalPath"`); 23 + return; 24 + } 25 + 26 + const [mode, signalPath] = parts.map((p) => p.trim()); 27 + 28 + switch (mode) { 29 + case "restore": { 30 + handleScrollRestore(context, signalPath); 31 + break; 32 + } 33 + case "scrollTo": { 34 + handleScrollTo(context, signalPath); 35 + break; 36 + } 37 + case "spy": { 38 + handleScrollSpy(context, signalPath); 39 + break; 40 + } 41 + case "smooth": { 42 + handleSmoothScroll(context, signalPath); 43 + break; 44 + } 45 + default: { 46 + console.error(`Unknown scroll mode: "${mode}"`); 47 + } 48 + } 49 + } 50 + 51 + /** 52 + * Save and restore scroll position. 53 + * Saves current scroll position to signal on scroll events. 54 + * Restores scroll position from signal on mount. 55 + */ 56 + function handleScrollRestore(context: PluginContext, signalPath: string): void { 57 + const signal = context.findSignal(signalPath); 58 + if (!signal) { 59 + console.error(`Signal "${signalPath}" not found for scroll restore`); 60 + return; 61 + } 62 + 63 + const element = context.element as HTMLElement; 64 + const savedPosition = signal.get(); 65 + if (typeof savedPosition === "number") { 66 + element.scrollTop = savedPosition; 67 + } 68 + 69 + const savePosition = () => { 70 + (signal as Signal<number>).set(element.scrollTop); 71 + }; 72 + 73 + element.addEventListener("scroll", savePosition, { passive: true }); 74 + 75 + context.addCleanup(() => { 76 + element.removeEventListener("scroll", savePosition); 77 + }); 78 + } 79 + 80 + /** 81 + * Scroll to element when signal value matches element's ID or selector. 82 + * Listens for changes to the target signal and scrolls to this element. 83 + */ 84 + function handleScrollTo(context: PluginContext, signalPath: string): void { 85 + const signal = context.findSignal(signalPath); 86 + if (!signal) { 87 + console.error(`Signal "${signalPath}" not found for scrollTo`); 88 + return; 89 + } 90 + 91 + const element = context.element as HTMLElement; 92 + const elementId = element.id; 93 + 94 + const checkAndScroll = (target: unknown) => { 95 + if (target === elementId || target === `#${elementId}`) { 96 + element.scrollIntoView({ behavior: "smooth", block: "start" }); 97 + } 98 + }; 99 + 100 + checkAndScroll(signal.get()); 101 + 102 + const unsubscribe = signal.subscribe(checkAndScroll); 103 + context.addCleanup(unsubscribe); 104 + } 105 + 106 + /** 107 + * Update signal when element enters or exits viewport. 108 + * Uses Intersection Observer to track visibility. 109 + */ 110 + function handleScrollSpy(context: PluginContext, signalPath: string): void { 111 + const signal = context.findSignal(signalPath); 112 + if (!signal) { 113 + console.error(`Signal "${signalPath}" not found for scroll spy`); 114 + return; 115 + } 116 + 117 + const element = context.element as HTMLElement; 118 + 119 + const observer = new IntersectionObserver((entries) => { 120 + for (const entry of entries) { 121 + if (entry.target === element) { 122 + (signal as Signal<boolean>).set(entry.isIntersecting); 123 + } 124 + } 125 + }, { threshold: 0.1 }); 126 + 127 + observer.observe(element); 128 + 129 + context.addCleanup(() => { 130 + observer.disconnect(); 131 + }); 132 + } 133 + 134 + /** 135 + * Enable smooth scrolling behavior. 136 + * Applies smooth scroll behavior based on signal value. 137 + */ 138 + function handleSmoothScroll(context: PluginContext, signalPath: string): void { 139 + const signal = context.findSignal(signalPath); 140 + if (!signal) { 141 + console.error(`Signal "${signalPath}" not found for smooth scroll`); 142 + return; 143 + } 144 + 145 + const element = context.element as HTMLElement; 146 + 147 + const applyBehavior = (value: unknown) => { 148 + if (value === true || value === "smooth") { 149 + element.style.scrollBehavior = "smooth"; 150 + } else if (value === false || value === "auto") { 151 + element.style.scrollBehavior = "auto"; 152 + } 153 + }; 154 + 155 + applyBehavior(signal.get()); 156 + 157 + const unsubscribe = signal.subscribe(applyBehavior); 158 + 159 + context.addCleanup(() => { 160 + unsubscribe(); 161 + element.style.scrollBehavior = ""; 162 + }); 163 + }
+216
src/plugins/url.ts
··· 1 + /** 2 + * URL plugin for synchronizing signals with URL parameters and hash routing 3 + * Supports one-way read, bidirectional sync, and hash-based routing 4 + */ 5 + 6 + import type { PluginContext, Signal } from "../types/volt"; 7 + 8 + /** 9 + * URL plugin handler. 10 + * Synchronizes signal values with URL parameters and hash. 11 + * 12 + * Syntax: data-x-url="mode:signalPath" 13 + * Modes: 14 + * - read:signalPath - Read URL param into signal on mount (one-way) 15 + * - sync:signalPath - Bidirectional sync between signal and URL param 16 + * - hash:signalPath - Sync with hash portion for routing 17 + */ 18 + export function urlPlugin(context: PluginContext, value: string): void { 19 + const parts = value.split(":"); 20 + if (parts.length !== 2) { 21 + console.error(`Invalid url binding: "${value}". Expected format: "mode:signalPath"`); 22 + return; 23 + } 24 + 25 + const [mode, signalPath] = parts.map((p) => p.trim()); 26 + 27 + switch (mode) { 28 + case "read": { 29 + handleUrlRead(context, signalPath); 30 + break; 31 + } 32 + case "sync": { 33 + handleUrlSync(context, signalPath); 34 + break; 35 + } 36 + case "hash": { 37 + handleHashRouting(context, signalPath); 38 + break; 39 + } 40 + default: { 41 + console.error(`Unknown url mode: "${mode}"`); 42 + } 43 + } 44 + } 45 + 46 + /** 47 + * Read URL parameter into signal on mount (one-way). 48 + * Signal changes do not update URL. 49 + */ 50 + function handleUrlRead(context: PluginContext, signalPath: string): void { 51 + const signal = context.findSignal(signalPath); 52 + if (!signal) { 53 + console.error(`Signal "${signalPath}" not found for url read`); 54 + return; 55 + } 56 + 57 + const params = new URLSearchParams(globalThis.location.search); 58 + const paramValue = params.get(signalPath); 59 + 60 + if (paramValue !== null) { 61 + (signal as Signal<unknown>).set(deserializeValue(paramValue)); 62 + } 63 + } 64 + 65 + /** 66 + * Bidirectional sync between signal and URL parameter. 67 + * Changes to either the signal or URL update the other. 68 + */ 69 + function handleUrlSync(context: PluginContext, signalPath: string): void { 70 + const signal = context.findSignal(signalPath); 71 + if (!signal) { 72 + console.error(`Signal "${signalPath}" not found for url sync`); 73 + return; 74 + } 75 + 76 + const params = new URLSearchParams(globalThis.location.search); 77 + const paramValue = params.get(signalPath); 78 + if (paramValue !== null) { 79 + (signal as Signal<unknown>).set(deserializeValue(paramValue)); 80 + } 81 + 82 + let isUpdatingFromUrl = false; 83 + let updateTimeout: number | undefined; 84 + 85 + const updateUrl = (value: unknown) => { 86 + if (isUpdatingFromUrl) return; 87 + 88 + if (updateTimeout) { 89 + clearTimeout(updateTimeout); 90 + } 91 + 92 + updateTimeout = setTimeout(() => { 93 + const params = new URLSearchParams(globalThis.location.search); 94 + const serialized = serializeValue(value); 95 + 96 + if (serialized === null || serialized === "") { 97 + params.delete(signalPath); 98 + } else { 99 + params.set(signalPath, serialized); 100 + } 101 + 102 + const newSearch = params.toString(); 103 + const newUrl = newSearch ? `?${newSearch}` : globalThis.location.pathname; 104 + 105 + globalThis.history.pushState({}, "", newUrl); 106 + }, 100) as unknown as number; 107 + }; 108 + 109 + const handlePopState = () => { 110 + isUpdatingFromUrl = true; 111 + const params = new URLSearchParams(globalThis.location.search); 112 + const paramValue = params.get(signalPath); 113 + 114 + if (paramValue === null) { 115 + (signal as Signal<unknown>).set(""); 116 + } else { 117 + (signal as Signal<unknown>).set(deserializeValue(paramValue)); 118 + } 119 + isUpdatingFromUrl = false; 120 + }; 121 + 122 + const unsubscribe = signal.subscribe(updateUrl); 123 + globalThis.addEventListener("popstate", handlePopState); 124 + 125 + context.addCleanup(() => { 126 + unsubscribe(); 127 + globalThis.removeEventListener("popstate", handlePopState); 128 + if (updateTimeout) { 129 + clearTimeout(updateTimeout); 130 + } 131 + }); 132 + } 133 + 134 + /** 135 + * Sync signal with hash portion of URL for client-side routing. 136 + * Bidirectional sync between signal and window.location.hash. 137 + */ 138 + function handleHashRouting(context: PluginContext, signalPath: string): void { 139 + const signal = context.findSignal(signalPath); 140 + if (!signal) { 141 + console.error(`Signal "${signalPath}" not found for hash routing`); 142 + return; 143 + } 144 + 145 + const currentHash = globalThis.location.hash.slice(1); 146 + if (currentHash) { 147 + (signal as Signal<string>).set(currentHash); 148 + } 149 + 150 + let isUpdatingFromHash = false; 151 + 152 + const updateHash = (value: unknown) => { 153 + if (isUpdatingFromHash) return; 154 + 155 + const hashValue = String(value ?? ""); 156 + const newHash = hashValue ? `#${hashValue}` : ""; 157 + 158 + if (globalThis.location.hash !== newHash) { 159 + globalThis.history.pushState({}, "", newHash || globalThis.location.pathname); 160 + } 161 + }; 162 + 163 + const handleHashChange = () => { 164 + isUpdatingFromHash = true; 165 + const currentHash = globalThis.location.hash.slice(1); 166 + (signal as Signal<string>).set(currentHash); 167 + isUpdatingFromHash = false; 168 + }; 169 + 170 + const unsubscribe = signal.subscribe(updateHash); 171 + globalThis.addEventListener("hashchange", handleHashChange); 172 + 173 + context.addCleanup(() => { 174 + unsubscribe(); 175 + globalThis.removeEventListener("hashchange", handleHashChange); 176 + }); 177 + } 178 + 179 + /** 180 + * Serialize a value for URL parameter storage. 181 + * 182 + * Handles strings, numbers, booleans, and No Value (null/undefined). 183 + */ 184 + function serializeValue(value: unknown): string { 185 + if (value === null || value === undefined) { 186 + return ""; 187 + } 188 + if (typeof value === "string") { 189 + return value; 190 + } 191 + if (typeof value === "number" || typeof value === "boolean") { 192 + return String(value); 193 + } 194 + return JSON.stringify(value); 195 + } 196 + 197 + /** 198 + * Deserialize a URL parameter value by attempting to parse as JSON, falls back to string. 199 + */ 200 + function deserializeValue(value: string): unknown { 201 + if (value === "true") return true; 202 + if (value === "false") return false; 203 + if (value === "null") return null; 204 + if (value === "undefined") return undefined; 205 + 206 + const numberValue = Number(value); 207 + if (!Number.isNaN(numberValue) && value !== "") { 208 + return numberValue; 209 + } 210 + 211 + try { 212 + return JSON.parse(value); 213 + } catch { 214 + return value; 215 + } 216 + }
+104
src/types/volt.d.ts
··· 1 + export type CleanupFunction = () => void; 2 + 3 + export type Scope = Record<string, unknown>; 4 + 5 + /** 6 + * Context object available to all bindings 7 + */ 8 + export interface BindingContext { 9 + element: Element; 10 + scope: Scope; 11 + cleanups: CleanupFunction[]; 12 + } 13 + 14 + /** 15 + * Context object provided to plugin handlers. 16 + * Contains utilities and references for implementing custom bindings. 17 + */ 18 + export interface PluginContext { 19 + /** 20 + * The DOM element the plugin is bound to 21 + */ 22 + element: Element; 23 + 24 + /** 25 + * The scope object containing signals and data 26 + */ 27 + scope: Scope; 28 + 29 + /** 30 + * Register a cleanup function to be called on unmount. 31 + * Plugins should use this to clean up subscriptions, event listeners, etc. 32 + */ 33 + addCleanup(fn: CleanupFunction): void; 34 + 35 + /** 36 + * Find a signal in the scope by property path. 37 + * Returns undefined if not found or if the value is not a signal. 38 + */ 39 + findSignal(path: string): Signal<unknown> | undefined; 40 + 41 + /** 42 + * Evaluate an expression against the scope. 43 + * Handles simple property paths, literals, and signal unwrapping. 44 + */ 45 + evaluate(expression: string): unknown; 46 + } 47 + 48 + /** 49 + * Plugin handler function signature. 50 + * Receives context and the attribute value, performs binding setup. 51 + */ 52 + export type PluginHandler = (context: PluginContext, value: string) => void; 53 + 54 + /** 55 + * A reactive primitive that notifies subscribers when its value changes. 56 + */ 57 + export interface Signal<T> { 58 + /** 59 + * Get the current value of the signal. 60 + */ 61 + get(): T; 62 + 63 + /** 64 + * Update the signal's value. 65 + * 66 + * If the new value differs from the current value, subscribers will be notified. 67 + */ 68 + set(value: T): void; 69 + 70 + /** 71 + * Subscribe to changes in the signal's value. 72 + * 73 + * The callback is invoked with the new value whenever it changes. 74 + * 75 + * Returns an unsubscribe function to remove the subscription. 76 + */ 77 + subscribe(callback: (value: T) => void): () => void; 78 + } 79 + 80 + /** 81 + * A computed signal that derives its value from other signals. 82 + */ 83 + export interface ComputedSignal<T> { 84 + /** 85 + * Get the current computed value. 86 + */ 87 + get(): T; 88 + 89 + /** 90 + * Subscribe to changes in the computed value. 91 + * 92 + * Returns an unsubscribe function to remove the subscription. 93 + */ 94 + subscribe(callback: (value: T) => void): () => void; 95 + } 96 + 97 + /** 98 + * Storage adapter interface for custom persistence backends 99 + */ 100 + export interface StorageAdapter { 101 + get(key: string): Promise<unknown> | unknown; 102 + set(key: string, value: unknown): Promise<void> | void; 103 + remove(key: string): Promise<void> | void; 104 + }
+132
test/core/plugin.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "../../src/core/plugin"; 3 + 4 + describe("plugin system", () => { 5 + beforeEach(() => { 6 + clearPlugins(); 7 + }); 8 + 9 + describe("registerPlugin", () => { 10 + it("registers a plugin with a given name", () => { 11 + const handler = vi.fn(); 12 + registerPlugin("test", handler); 13 + 14 + expect(hasPlugin("test")).toBe(true); 15 + }); 16 + 17 + it("allows overwriting existing plugins with a warning", () => { 18 + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 19 + const handler1 = vi.fn(); 20 + const handler2 = vi.fn(); 21 + 22 + registerPlugin("test", handler1); 23 + registerPlugin("test", handler2); 24 + 25 + expect(warnSpy).toHaveBeenCalledWith("Plugin \"test\" is already registered. Overwriting."); 26 + expect(hasPlugin("test")).toBe(true); 27 + 28 + warnSpy.mockRestore(); 29 + }); 30 + 31 + it("registers multiple plugins independently", () => { 32 + const handler1 = vi.fn(); 33 + const handler2 = vi.fn(); 34 + 35 + registerPlugin("plugin1", handler1); 36 + registerPlugin("plugin2", handler2); 37 + 38 + expect(hasPlugin("plugin1")).toBe(true); 39 + expect(hasPlugin("plugin2")).toBe(true); 40 + }); 41 + }); 42 + 43 + describe("hasPlugin", () => { 44 + it("returns true for registered plugins", () => { 45 + const handler = vi.fn(); 46 + registerPlugin("test", handler); 47 + 48 + expect(hasPlugin("test")).toBe(true); 49 + }); 50 + 51 + it("returns false for unregistered plugins", () => { 52 + expect(hasPlugin("nonexistent")).toBe(false); 53 + }); 54 + 55 + it("returns false after plugin is unregistered", () => { 56 + const handler = vi.fn(); 57 + registerPlugin("test", handler); 58 + unregisterPlugin("test"); 59 + 60 + expect(hasPlugin("test")).toBe(false); 61 + }); 62 + }); 63 + 64 + describe("unregisterPlugin", () => { 65 + it("unregisters a plugin and returns true", () => { 66 + const handler = vi.fn(); 67 + registerPlugin("test", handler); 68 + 69 + const result = unregisterPlugin("test"); 70 + 71 + expect(result).toBe(true); 72 + expect(hasPlugin("test")).toBe(false); 73 + }); 74 + 75 + it("returns false when unregistering nonexistent plugin", () => { 76 + const result = unregisterPlugin("nonexistent"); 77 + 78 + expect(result).toBe(false); 79 + }); 80 + }); 81 + 82 + describe("getRegisteredPlugins", () => { 83 + it("returns empty array when no plugins registered", () => { 84 + expect(getRegisteredPlugins()).toEqual([]); 85 + }); 86 + 87 + it("returns array of registered plugin names", () => { 88 + const handler = vi.fn(); 89 + 90 + registerPlugin("plugin1", handler); 91 + registerPlugin("plugin2", handler); 92 + registerPlugin("plugin3", handler); 93 + 94 + const plugins = getRegisteredPlugins(); 95 + 96 + expect(plugins).toHaveLength(3); 97 + expect(plugins).toContain("plugin1"); 98 + expect(plugins).toContain("plugin2"); 99 + expect(plugins).toContain("plugin3"); 100 + }); 101 + 102 + it("updates when plugins are added or removed", () => { 103 + const handler = vi.fn(); 104 + 105 + registerPlugin("plugin1", handler); 106 + expect(getRegisteredPlugins()).toEqual(["plugin1"]); 107 + 108 + registerPlugin("plugin2", handler); 109 + expect(getRegisteredPlugins()).toHaveLength(2); 110 + 111 + unregisterPlugin("plugin1"); 112 + expect(getRegisteredPlugins()).toEqual(["plugin2"]); 113 + }); 114 + }); 115 + 116 + describe("clearPlugins", () => { 117 + it("removes all registered plugins", () => { 118 + const handler = vi.fn(); 119 + 120 + registerPlugin("plugin1", handler); 121 + registerPlugin("plugin2", handler); 122 + registerPlugin("plugin3", handler); 123 + 124 + clearPlugins(); 125 + 126 + expect(getRegisteredPlugins()).toEqual([]); 127 + expect(hasPlugin("plugin1")).toBe(false); 128 + expect(hasPlugin("plugin2")).toBe(false); 129 + expect(hasPlugin("plugin3")).toBe(false); 130 + }); 131 + }); 132 + });
+171
test/integration/plugins.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { mount } from "../../src/core/binder"; 3 + import { clearPlugins, registerPlugin } from "../../src/core/plugin"; 4 + import { signal } from "../../src/core/signal"; 5 + 6 + describe("plugin integration with binder", () => { 7 + beforeEach(() => { 8 + clearPlugins(); 9 + }); 10 + 11 + it("calls registered plugin when binding attribute", () => { 12 + const pluginHandler = vi.fn(); 13 + registerPlugin("custom", pluginHandler); 14 + 15 + const element = document.createElement("div"); 16 + element.dataset.xCustom = "testValue"; 17 + 18 + const scope = { test: "value" }; 19 + mount(element, scope); 20 + 21 + expect(pluginHandler).toHaveBeenCalledOnce(); 22 + expect(pluginHandler).toHaveBeenCalledWith( 23 + expect.objectContaining({ 24 + element, 25 + scope, 26 + addCleanup: expect.any(Function), 27 + findSignal: expect.any(Function), 28 + evaluate: expect.any(Function), 29 + }), 30 + "testValue", 31 + ); 32 + }); 33 + 34 + it("warns when unknown binding is used without plugin", () => { 35 + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 36 + const element = document.createElement("div"); 37 + element.dataset.xUnknown = "value"; 38 + 39 + mount(element, {}); 40 + 41 + expect(warnSpy).toHaveBeenCalledWith("Unknown binding: data-x-unknown"); 42 + 43 + warnSpy.mockRestore(); 44 + }); 45 + 46 + it("provides working findSignal utility to plugin", () => { 47 + let foundSignal: unknown; 48 + registerPlugin("finder", (context) => { 49 + foundSignal = context.findSignal("count"); 50 + }); 51 + 52 + const element = document.createElement("div"); 53 + element.dataset.xFinder = "test"; 54 + 55 + const count = signal(42); 56 + mount(element, { count }); 57 + 58 + expect(foundSignal).toBe(count); 59 + }); 60 + 61 + it("provides working evaluate utility to plugin", () => { 62 + let evaluatedValue: unknown; 63 + registerPlugin("evaluator", (context, value) => { 64 + evaluatedValue = context.evaluate(value); 65 + }); 66 + 67 + const element = document.createElement("div"); 68 + element.dataset.xEvaluator = "count"; 69 + 70 + const count = signal(100); 71 + mount(element, { count }); 72 + 73 + expect(evaluatedValue).toBe(100); 74 + }); 75 + 76 + it("registers and calls cleanup functions", () => { 77 + const cleanup = vi.fn(); 78 + registerPlugin("cleaner", (context) => { 79 + context.addCleanup(cleanup); 80 + }); 81 + 82 + const element = document.createElement("div"); 83 + element.dataset.xCleaner = "test"; 84 + 85 + const unmount = mount(element, {}); 86 + 87 + expect(cleanup).not.toHaveBeenCalled(); 88 + 89 + unmount(); 90 + 91 + expect(cleanup).toHaveBeenCalledOnce(); 92 + }); 93 + 94 + it("handles multiple plugins on same element", () => { 95 + const plugin1 = vi.fn(); 96 + const plugin2 = vi.fn(); 97 + 98 + registerPlugin("plugin1", plugin1); 99 + registerPlugin("plugin2", plugin2); 100 + 101 + const element = document.createElement("div"); 102 + element.dataset.xPlugin1 = "value1"; 103 + element.dataset.xPlugin2 = "value2"; 104 + 105 + mount(element, {}); 106 + 107 + expect(plugin1).toHaveBeenCalledWith(expect.anything(), "value1"); 108 + expect(plugin2).toHaveBeenCalledWith(expect.anything(), "value2"); 109 + }); 110 + 111 + it("allows plugins to work alongside core bindings", () => { 112 + const pluginHandler = vi.fn(); 113 + registerPlugin("custom", pluginHandler); 114 + 115 + const element = document.createElement("div"); 116 + element.dataset.xText = "message"; 117 + element.dataset.xCustom = "customValue"; 118 + 119 + const scope = { message: "Hello" }; 120 + mount(element, scope); 121 + 122 + expect(element.textContent).toBe("Hello"); 123 + expect(pluginHandler).toHaveBeenCalledWith(expect.anything(), "customValue"); 124 + }); 125 + 126 + it("handles plugin errors gracefully", () => { 127 + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 128 + const badPlugin = vi.fn(() => { 129 + throw new Error("Plugin error"); 130 + }); 131 + 132 + registerPlugin("bad", badPlugin); 133 + 134 + const element = document.createElement("div"); 135 + element.dataset.xBad = "value"; 136 + 137 + mount(element, {}); 138 + 139 + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Error in plugin \"bad\""), expect.any(Error)); 140 + 141 + errorSpy.mockRestore(); 142 + }); 143 + 144 + it("supports reactive updates from plugins", () => { 145 + registerPlugin("reactive", (context, value) => { 146 + const sig = context.findSignal(value); 147 + if (sig) { 148 + const update = () => { 149 + (context.element as HTMLElement).dataset.testValue = String(sig.get()); 150 + }; 151 + update(); 152 + const unsubscribe = sig.subscribe(update); 153 + context.addCleanup(unsubscribe); 154 + } 155 + }); 156 + 157 + const element = document.createElement("div"); 158 + element.dataset.xReactive = "count"; 159 + 160 + const count = signal(1); 161 + mount(element, { count }); 162 + 163 + expect(element.dataset.testValue).toBe("1"); 164 + 165 + count.set(5); 166 + expect(element.dataset.testValue).toBe("5"); 167 + 168 + count.set(10); 169 + expect(element.dataset.testValue).toBe("10"); 170 + }); 171 + });
+266
test/plugins/persist.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { mount } from "../../src/core/binder"; 3 + import { registerPlugin } from "../../src/core/plugin"; 4 + import { signal } from "../../src/core/signal"; 5 + import { persistPlugin, registerStorageAdapter } from "../../src/plugins/persist"; 6 + 7 + describe("persist plugin", () => { 8 + beforeEach(() => { 9 + localStorage.clear(); 10 + sessionStorage.clear(); 11 + registerPlugin("persist", persistPlugin); 12 + }); 13 + 14 + describe("localStorage persistence", () => { 15 + it("loads persisted value from localStorage on mount", () => { 16 + localStorage.setItem("volt:count", "42"); 17 + 18 + const element = document.createElement("div"); 19 + element.dataset.xPersist = "count:local"; 20 + 21 + const count = signal(0); 22 + mount(element, { count }); 23 + 24 + expect(count.get()).toBe(42); 25 + }); 26 + 27 + it("saves signal value to localStorage on change", async () => { 28 + const element = document.createElement("div"); 29 + element.dataset.xPersist = "count:local"; 30 + 31 + const count = signal(0); 32 + mount(element, { count }); 33 + 34 + count.set(99); 35 + 36 + await new Promise((resolve) => setTimeout(resolve, 0)); 37 + 38 + expect(localStorage.getItem("volt:count")).toBe("99"); 39 + }); 40 + 41 + it("persists string values", async () => { 42 + const element = document.createElement("div"); 43 + element.dataset.xPersist = "name:local"; 44 + 45 + const name = signal("Alice"); 46 + mount(element, { name }); 47 + 48 + name.set("Bob"); 49 + 50 + await new Promise((resolve) => setTimeout(resolve, 0)); 51 + 52 + expect(localStorage.getItem("volt:name")).toBe('"Bob"'); 53 + }); 54 + 55 + it("persists object values", async () => { 56 + const element = document.createElement("div"); 57 + element.dataset.xPersist = "user:local"; 58 + 59 + const user = signal({ name: "Alice", age: 30 }); 60 + mount(element, { user }); 61 + 62 + user.set({ name: "Bob", age: 35 }); 63 + 64 + await new Promise((resolve) => setTimeout(resolve, 0)); 65 + 66 + const stored = localStorage.getItem("volt:user"); 67 + expect(stored).toBe('{"name":"Bob","age":35}'); 68 + }); 69 + 70 + it("does not override signal if localStorage is empty", () => { 71 + const element = document.createElement("div"); 72 + element.dataset.xPersist = "count:local"; 73 + 74 + const count = signal(100); 75 + mount(element, { count }); 76 + 77 + expect(count.get()).toBe(100); 78 + }); 79 + }); 80 + 81 + describe("sessionStorage persistence", () => { 82 + it("loads persisted value from sessionStorage on mount", () => { 83 + sessionStorage.setItem("volt:sessionData", "123"); 84 + 85 + const element = document.createElement("div"); 86 + element.dataset.xPersist = "sessionData:session"; 87 + 88 + const sessionData = signal(0); 89 + mount(element, { sessionData }); 90 + 91 + expect(sessionData.get()).toBe(123); 92 + }); 93 + 94 + it("saves signal value to sessionStorage on change", async () => { 95 + const element = document.createElement("div"); 96 + element.dataset.xPersist = "sessionData:session"; 97 + 98 + const sessionData = signal(0); 99 + mount(element, { sessionData }); 100 + 101 + sessionData.set(456); 102 + 103 + await new Promise((resolve) => setTimeout(resolve, 0)); 104 + 105 + expect(sessionStorage.getItem("volt:sessionData")).toBe("456"); 106 + }); 107 + }); 108 + 109 + describe("custom storage adapters", () => { 110 + it("allows registering custom storage adapter", async () => { 111 + const customStore = new Map<string, unknown>(); 112 + registerStorageAdapter("custom", { 113 + get: (key) => customStore.get(key), 114 + set: (key, value) => { 115 + customStore.set(key, value); 116 + }, 117 + remove: (key) => { 118 + customStore.delete(key); 119 + }, 120 + }); 121 + 122 + customStore.set("volt:data", 999); 123 + 124 + const element = document.createElement("div"); 125 + element.dataset.xPersist = "data:custom"; 126 + 127 + const data = signal(0); 128 + mount(element, { data }); 129 + 130 + expect(data.get()).toBe(999); 131 + 132 + data.set(777); 133 + 134 + await new Promise((resolve) => setTimeout(resolve, 0)); 135 + 136 + expect(customStore.get("volt:data")).toBe(777); 137 + }); 138 + 139 + it("supports async custom adapters", async () => { 140 + const customStore = new Map<string, unknown>(); 141 + registerStorageAdapter("async", { 142 + get: async (key) => { 143 + await new Promise((resolve) => setTimeout(resolve, 10)); 144 + return customStore.get(key); 145 + }, 146 + set: async (key, value) => { 147 + await new Promise((resolve) => setTimeout(resolve, 10)); 148 + customStore.set(key, value); 149 + }, 150 + remove: async (key) => { 151 + await new Promise((resolve) => setTimeout(resolve, 10)); 152 + customStore.delete(key); 153 + }, 154 + }); 155 + 156 + customStore.set("volt:asyncData", 888); 157 + 158 + const element = document.createElement("div"); 159 + element.dataset.xPersist = "asyncData:async"; 160 + 161 + const asyncData = signal(0); 162 + mount(element, { asyncData }); 163 + 164 + await new Promise((resolve) => setTimeout(resolve, 20)); 165 + 166 + expect(asyncData.get()).toBe(888); 167 + }); 168 + }); 169 + 170 + describe("error handling", () => { 171 + it("logs error for invalid binding format", () => { 172 + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 173 + const element = document.createElement("div"); 174 + element.dataset.xPersist = "invalidformat"; 175 + 176 + mount(element, {}); 177 + 178 + expect(errorSpy).toHaveBeenCalledWith( 179 + expect.stringContaining("Invalid persist binding"), 180 + ); 181 + 182 + errorSpy.mockRestore(); 183 + }); 184 + 185 + it("logs error when signal not found", () => { 186 + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 187 + const element = document.createElement("div"); 188 + element.dataset.xPersist = "nonexistent:local"; 189 + 190 + mount(element, {}); 191 + 192 + expect(errorSpy).toHaveBeenCalledWith( 193 + expect.stringContaining('Signal "nonexistent" not found'), 194 + ); 195 + 196 + errorSpy.mockRestore(); 197 + }); 198 + 199 + it("logs error for unknown storage type", () => { 200 + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 201 + const element = document.createElement("div"); 202 + element.dataset.xPersist = "data:unknown"; 203 + 204 + const data = signal(0); 205 + mount(element, { data }); 206 + 207 + expect(errorSpy).toHaveBeenCalledWith( 208 + expect.stringContaining('Unknown storage type: "unknown"'), 209 + ); 210 + 211 + errorSpy.mockRestore(); 212 + }); 213 + 214 + it("handles storage adapter errors gracefully", async () => { 215 + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 216 + 217 + registerStorageAdapter("faulty", { 218 + get: () => { 219 + throw new Error("Read error"); 220 + }, 221 + set: () => { 222 + throw new Error("Write error"); 223 + }, 224 + remove: () => {}, 225 + }); 226 + 227 + const element = document.createElement("div"); 228 + element.dataset.xPersist = "data:faulty"; 229 + 230 + const data = signal(0); 231 + mount(element, { data }); 232 + 233 + await new Promise((resolve) => setTimeout(resolve, 0)); 234 + 235 + expect(errorSpy).toHaveBeenCalled(); 236 + 237 + data.set(1); 238 + 239 + await new Promise((resolve) => setTimeout(resolve, 0)); 240 + 241 + expect(errorSpy).toHaveBeenCalled(); 242 + 243 + errorSpy.mockRestore(); 244 + }); 245 + }); 246 + 247 + describe("cleanup", () => { 248 + it("stops persisting after unmount", async () => { 249 + const element = document.createElement("div"); 250 + element.dataset.xPersist = "count:local"; 251 + 252 + const count = signal(0); 253 + const cleanup = mount(element, { count }); 254 + 255 + count.set(10); 256 + await new Promise((resolve) => setTimeout(resolve, 0)); 257 + expect(localStorage.getItem("volt:count")).toBe("10"); 258 + 259 + cleanup(); 260 + 261 + count.set(20); 262 + await new Promise((resolve) => setTimeout(resolve, 0)); 263 + expect(localStorage.getItem("volt:count")).toBe("10"); 264 + }); 265 + }); 266 + });
+346
test/plugins/scroll.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { mount } from "../../src/core/binder"; 3 + import { registerPlugin } from "../../src/core/plugin"; 4 + import { signal } from "../../src/core/signal"; 5 + import { scrollPlugin } from "../../src/plugins/scroll"; 6 + 7 + describe("scroll plugin", () => { 8 + beforeEach(() => { 9 + registerPlugin("scroll", scrollPlugin); 10 + }); 11 + 12 + describe("restore mode", () => { 13 + it("restores scroll position from signal on mount", () => { 14 + const element = document.createElement("div"); 15 + element.dataset.xScroll = "restore:scrollPos"; 16 + Object.defineProperty(element, "scrollTop", { 17 + writable: true, 18 + value: 0, 19 + }); 20 + 21 + const scrollPos = signal(250); 22 + mount(element, { scrollPos }); 23 + 24 + expect(element.scrollTop).toBe(250); 25 + }); 26 + 27 + it("saves scroll position to signal on scroll", () => { 28 + const element = document.createElement("div"); 29 + element.dataset.xScroll = "restore:scrollPos"; 30 + 31 + const scrollPos = signal(0); 32 + mount(element, { scrollPos }); 33 + 34 + Object.defineProperty(element, "scrollTop", { 35 + writable: true, 36 + value: 100, 37 + }); 38 + 39 + element.dispatchEvent(new Event("scroll")); 40 + 41 + expect(scrollPos.get()).toBe(100); 42 + }); 43 + 44 + it("does not restore if signal value is not a number", () => { 45 + const element = document.createElement("div"); 46 + element.dataset.xScroll = "restore:scrollPos"; 47 + Object.defineProperty(element, "scrollTop", { 48 + writable: true, 49 + value: 0, 50 + }); 51 + 52 + const scrollPos = signal("not a number" as unknown as number); 53 + mount(element, { scrollPos }); 54 + 55 + expect(element.scrollTop).toBe(0); 56 + }); 57 + 58 + it("cleans up scroll listener on unmount", () => { 59 + const element = document.createElement("div"); 60 + element.dataset.xScroll = "restore:scrollPos"; 61 + 62 + const scrollPos = signal(0); 63 + const cleanup = mount(element, { scrollPos }); 64 + 65 + Object.defineProperty(element, "scrollTop", { 66 + writable: true, 67 + value: 100, 68 + }); 69 + element.dispatchEvent(new Event("scroll")); 70 + expect(scrollPos.get()).toBe(100); 71 + 72 + cleanup(); 73 + 74 + Object.defineProperty(element, "scrollTop", { 75 + writable: true, 76 + value: 200, 77 + }); 78 + element.dispatchEvent(new Event("scroll")); 79 + expect(scrollPos.get()).toBe(100); 80 + }); 81 + }); 82 + 83 + describe("scrollTo mode", () => { 84 + it("scrolls to element when signal matches element ID", () => { 85 + const element = document.createElement("div"); 86 + element.id = "section1"; 87 + element.dataset.xScroll = "scrollTo:targetId"; 88 + 89 + const scrollIntoViewMock = vi.fn(); 90 + element.scrollIntoView = scrollIntoViewMock; 91 + 92 + const targetId = signal(""); 93 + mount(element, { targetId }); 94 + 95 + targetId.set("section1"); 96 + 97 + expect(scrollIntoViewMock).toHaveBeenCalledWith({ 98 + behavior: "smooth", 99 + block: "start", 100 + }); 101 + }); 102 + 103 + it("scrolls to element when signal matches #elementId format", () => { 104 + const element = document.createElement("div"); 105 + element.id = "section2"; 106 + element.dataset.xScroll = "scrollTo:targetId"; 107 + 108 + const scrollIntoViewMock = vi.fn(); 109 + element.scrollIntoView = scrollIntoViewMock; 110 + 111 + const targetId = signal(""); 112 + mount(element, { targetId }); 113 + 114 + targetId.set("#section2"); 115 + 116 + expect(scrollIntoViewMock).toHaveBeenCalledWith({ 117 + behavior: "smooth", 118 + block: "start", 119 + }); 120 + }); 121 + 122 + it("does not scroll if signal does not match element ID", () => { 123 + const element = document.createElement("div"); 124 + element.id = "section1"; 125 + element.dataset.xScroll = "scrollTo:targetId"; 126 + 127 + const scrollIntoViewMock = vi.fn(); 128 + element.scrollIntoView = scrollIntoViewMock; 129 + 130 + const targetId = signal("otherSection"); 131 + mount(element, { targetId }); 132 + 133 + expect(scrollIntoViewMock).not.toHaveBeenCalled(); 134 + }); 135 + 136 + it("scrolls on initial mount if signal already matches", () => { 137 + const element = document.createElement("div"); 138 + element.id = "section1"; 139 + element.dataset.xScroll = "scrollTo:targetId"; 140 + 141 + const scrollIntoViewMock = vi.fn(); 142 + element.scrollIntoView = scrollIntoViewMock; 143 + 144 + const targetId = signal("section1"); 145 + mount(element, { targetId }); 146 + 147 + expect(scrollIntoViewMock).toHaveBeenCalledOnce(); 148 + }); 149 + }); 150 + 151 + describe("spy mode", () => { 152 + it("updates signal when element enters viewport", () => { 153 + const element = document.createElement("div"); 154 + element.dataset.xScroll = "spy:isVisible"; 155 + 156 + const isVisible = signal(false); 157 + 158 + let observerCallback!: IntersectionObserverCallback; 159 + const mockObserver = { 160 + observe: vi.fn(), 161 + disconnect: vi.fn(), 162 + unobserve: vi.fn(), 163 + takeRecords: vi.fn(), 164 + root: null, 165 + rootMargin: "", 166 + thresholds: [], 167 + }; 168 + 169 + (window as typeof globalThis).IntersectionObserver = vi.fn((callback) => { 170 + observerCallback = callback; 171 + return mockObserver; 172 + }) as unknown as typeof IntersectionObserver; 173 + 174 + mount(element, { isVisible }); 175 + 176 + expect(mockObserver.observe).toHaveBeenCalledWith(element); 177 + 178 + observerCallback( 179 + [ 180 + { 181 + isIntersecting: true, 182 + target: element, 183 + } as unknown as IntersectionObserverEntry, 184 + ], 185 + mockObserver as IntersectionObserver, 186 + ); 187 + 188 + expect(isVisible.get()).toBe(true); 189 + 190 + observerCallback( 191 + [ 192 + { 193 + isIntersecting: false, 194 + target: element, 195 + } as unknown as IntersectionObserverEntry, 196 + ], 197 + mockObserver as IntersectionObserver, 198 + ); 199 + 200 + expect(isVisible.get()).toBe(false); 201 + }); 202 + 203 + it("disconnects observer on cleanup", () => { 204 + const element = document.createElement("div"); 205 + element.dataset.xScroll = "spy:isVisible"; 206 + 207 + const isVisible = signal(false); 208 + 209 + const mockObserver = { 210 + observe: vi.fn(), 211 + disconnect: vi.fn(), 212 + unobserve: vi.fn(), 213 + takeRecords: vi.fn(), 214 + root: null, 215 + rootMargin: "", 216 + thresholds: [], 217 + }; 218 + 219 + (window as typeof globalThis).IntersectionObserver = vi.fn(() => { 220 + return mockObserver; 221 + }) as unknown as typeof IntersectionObserver; 222 + 223 + const cleanup = mount(element, { isVisible }); 224 + 225 + cleanup(); 226 + 227 + expect(mockObserver.disconnect).toHaveBeenCalled(); 228 + }); 229 + }); 230 + 231 + describe("smooth mode", () => { 232 + it("applies smooth scroll behavior when signal is true", () => { 233 + const element = document.createElement("div"); 234 + element.dataset.xScroll = "smooth:smoothScroll"; 235 + 236 + const smoothScroll = signal(true); 237 + mount(element, { smoothScroll }); 238 + 239 + expect(element.style.scrollBehavior).toBe("smooth"); 240 + }); 241 + 242 + it("applies smooth scroll behavior when signal is 'smooth'", () => { 243 + const element = document.createElement("div"); 244 + element.dataset.xScroll = "smooth:smoothScroll"; 245 + 246 + const smoothScroll = signal("smooth"); 247 + mount(element, { smoothScroll }); 248 + 249 + expect(element.style.scrollBehavior).toBe("smooth"); 250 + }); 251 + 252 + it("applies auto scroll behavior when signal is false", () => { 253 + const element = document.createElement("div"); 254 + element.dataset.xScroll = "smooth:smoothScroll"; 255 + 256 + const smoothScroll = signal(false); 257 + mount(element, { smoothScroll }); 258 + 259 + expect(element.style.scrollBehavior).toBe("auto"); 260 + }); 261 + 262 + it("applies auto scroll behavior when signal is 'auto'", () => { 263 + const element = document.createElement("div"); 264 + element.dataset.xScroll = "smooth:smoothScroll"; 265 + 266 + const smoothScroll = signal("auto"); 267 + mount(element, { smoothScroll }); 268 + 269 + expect(element.style.scrollBehavior).toBe("auto"); 270 + }); 271 + 272 + it("updates scroll behavior when signal changes", () => { 273 + const element = document.createElement("div"); 274 + element.dataset.xScroll = "smooth:smoothScroll"; 275 + 276 + const smoothScroll = signal(false); 277 + mount(element, { smoothScroll }); 278 + 279 + expect(element.style.scrollBehavior).toBe("auto"); 280 + 281 + smoothScroll.set(true); 282 + expect(element.style.scrollBehavior).toBe("smooth"); 283 + 284 + smoothScroll.set(false); 285 + expect(element.style.scrollBehavior).toBe("auto"); 286 + }); 287 + 288 + it("resets scroll behavior on cleanup", () => { 289 + const element = document.createElement("div"); 290 + element.dataset.xScroll = "smooth:smoothScroll"; 291 + 292 + const smoothScroll = signal(true); 293 + const cleanup = mount(element, { smoothScroll }); 294 + 295 + expect(element.style.scrollBehavior).toBe("smooth"); 296 + 297 + cleanup(); 298 + 299 + expect(element.style.scrollBehavior).toBe(""); 300 + }); 301 + }); 302 + 303 + describe("error handling", () => { 304 + it("logs error for invalid binding format", () => { 305 + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 306 + const element = document.createElement("div"); 307 + element.dataset.xScroll = "invalidformat"; 308 + 309 + mount(element, {}); 310 + 311 + expect(errorSpy).toHaveBeenCalledWith( 312 + expect.stringContaining("Invalid scroll binding"), 313 + ); 314 + 315 + errorSpy.mockRestore(); 316 + }); 317 + 318 + it("logs error for unknown scroll mode", () => { 319 + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 320 + const element = document.createElement("div"); 321 + element.dataset.xScroll = "unknown:signal"; 322 + 323 + mount(element, {}); 324 + 325 + expect(errorSpy).toHaveBeenCalledWith( 326 + expect.stringContaining('Unknown scroll mode: "unknown"'), 327 + ); 328 + 329 + errorSpy.mockRestore(); 330 + }); 331 + 332 + it("logs error when signal not found", () => { 333 + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 334 + const element = document.createElement("div"); 335 + element.dataset.xScroll = "restore:nonexistent"; 336 + 337 + mount(element, {}); 338 + 339 + expect(errorSpy).toHaveBeenCalledWith( 340 + expect.stringContaining('Signal "nonexistent" not found'), 341 + ); 342 + 343 + errorSpy.mockRestore(); 344 + }); 345 + }); 346 + });
+321
test/plugins/url.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { mount } from "../../src/core/binder"; 3 + import { registerPlugin } from "../../src/core/plugin"; 4 + import { signal } from "../../src/core/signal"; 5 + import { urlPlugin } from "../../src/plugins/url"; 6 + 7 + describe("url plugin", () => { 8 + beforeEach(() => { 9 + registerPlugin("url", urlPlugin); 10 + window.history.replaceState({}, "", "/"); 11 + }); 12 + 13 + describe("read mode", () => { 14 + it("reads URL parameter into signal on mount", () => { 15 + window.history.replaceState({}, "", "/?tab=profile"); 16 + 17 + const element = document.createElement("div"); 18 + element.dataset.xUrl = "read:tab"; 19 + 20 + const tab = signal(""); 21 + mount(element, { tab }); 22 + 23 + expect(tab.get()).toBe("profile"); 24 + }); 25 + 26 + it("does not update URL when signal changes", async () => { 27 + window.history.replaceState({}, "", "/?tab=home"); 28 + 29 + const element = document.createElement("div"); 30 + element.dataset.xUrl = "read:tab"; 31 + 32 + const tab = signal(""); 33 + mount(element, { tab }); 34 + 35 + tab.set("settings"); 36 + 37 + await new Promise((resolve) => setTimeout(resolve, 200)); 38 + 39 + expect(window.location.search).toBe("?tab=home"); 40 + }); 41 + 42 + it("handles missing URL parameter", () => { 43 + window.history.replaceState({}, "", "/"); 44 + 45 + const element = document.createElement("div"); 46 + element.dataset.xUrl = "read:missing"; 47 + 48 + const missing = signal("default"); 49 + mount(element, { missing }); 50 + 51 + expect(missing.get()).toBe("default"); 52 + }); 53 + 54 + it("deserializes boolean values", () => { 55 + window.history.replaceState({}, "", "/?active=true"); 56 + 57 + const element = document.createElement("div"); 58 + element.dataset.xUrl = "read:active"; 59 + 60 + const active = signal(false); 61 + mount(element, { active }); 62 + 63 + expect(active.get()).toBe(true); 64 + }); 65 + 66 + it("deserializes number values", () => { 67 + window.history.replaceState({}, "", "/?count=42"); 68 + 69 + const element = document.createElement("div"); 70 + element.dataset.xUrl = "read:count"; 71 + 72 + const count = signal(0); 73 + mount(element, { count }); 74 + 75 + expect(count.get()).toBe(42); 76 + }); 77 + }); 78 + 79 + describe("sync mode", () => { 80 + it("reads URL parameter into signal on mount", () => { 81 + window.history.replaceState({}, "", "/?filter=active"); 82 + 83 + const element = document.createElement("div"); 84 + element.dataset.xUrl = "sync:filter"; 85 + 86 + const filter = signal(""); 87 + mount(element, { filter }); 88 + 89 + expect(filter.get()).toBe("active"); 90 + }); 91 + 92 + it("updates URL when signal changes", async () => { 93 + window.history.replaceState({}, "", "/"); 94 + 95 + const element = document.createElement("div"); 96 + element.dataset.xUrl = "sync:query"; 97 + 98 + const query = signal(""); 99 + mount(element, { query }); 100 + 101 + query.set("search term"); 102 + 103 + await new Promise((resolve) => setTimeout(resolve, 150)); 104 + 105 + expect(window.location.search).toContain("query=search+term"); 106 + }); 107 + 108 + it("removes parameter from URL when signal is empty", async () => { 109 + window.history.replaceState({}, "", "/?query=test"); 110 + 111 + const element = document.createElement("div"); 112 + element.dataset.xUrl = "sync:query"; 113 + 114 + const query = signal(""); 115 + mount(element, { query }); 116 + 117 + query.set(""); 118 + 119 + await new Promise((resolve) => setTimeout(resolve, 150)); 120 + 121 + expect(window.location.search).toBe(""); 122 + }); 123 + 124 + it("handles popstate events from browser navigation", () => { 125 + window.history.replaceState({}, "", "/?filter=all"); 126 + 127 + const element = document.createElement("div"); 128 + element.dataset.xUrl = "sync:filter"; 129 + 130 + const filter = signal(""); 131 + mount(element, { filter }); 132 + 133 + expect(filter.get()).toBe("all"); 134 + 135 + window.history.replaceState({}, "", "/?filter=completed"); 136 + window.dispatchEvent(new PopStateEvent("popstate")); 137 + 138 + expect(filter.get()).toBe("completed"); 139 + }); 140 + 141 + it("sets signal to empty string when parameter removed from URL", () => { 142 + window.history.replaceState({}, "", "/?filter=test"); 143 + 144 + const element = document.createElement("div"); 145 + element.dataset.xUrl = "sync:filter"; 146 + 147 + const filter = signal(""); 148 + mount(element, { filter }); 149 + 150 + expect(filter.get()).toBe("test"); 151 + 152 + window.history.replaceState({}, "", "/"); 153 + window.dispatchEvent(new PopStateEvent("popstate")); 154 + 155 + expect(filter.get()).toBe(""); 156 + }); 157 + 158 + it("debounces URL updates", async () => { 159 + const pushStateSpy = vi.spyOn(window.history, "pushState"); 160 + 161 + const element = document.createElement("div"); 162 + element.dataset.xUrl = "sync:query"; 163 + 164 + const query = signal(""); 165 + mount(element, { query }); 166 + 167 + query.set("a"); 168 + query.set("ab"); 169 + query.set("abc"); 170 + 171 + await new Promise((resolve) => setTimeout(resolve, 50)); 172 + expect(pushStateSpy).not.toHaveBeenCalled(); 173 + 174 + await new Promise((resolve) => setTimeout(resolve, 100)); 175 + expect(pushStateSpy).toHaveBeenCalledOnce(); 176 + 177 + pushStateSpy.mockRestore(); 178 + }); 179 + 180 + it("cleans up popstate listener on unmount", () => { 181 + window.history.replaceState({}, "", "/?filter=test"); 182 + 183 + const element = document.createElement("div"); 184 + element.dataset.xUrl = "sync:filter"; 185 + 186 + const filter = signal(""); 187 + const cleanup = mount(element, { filter }); 188 + 189 + expect(filter.get()).toBe("test"); 190 + 191 + cleanup(); 192 + 193 + window.history.replaceState({}, "", "/?filter=other"); 194 + window.dispatchEvent(new PopStateEvent("popstate")); 195 + 196 + expect(filter.get()).toBe("test"); 197 + }); 198 + }); 199 + 200 + describe("hash mode", () => { 201 + it("reads hash into signal on mount", () => { 202 + window.location.hash = "#/about"; 203 + 204 + const element = document.createElement("div"); 205 + element.dataset.xUrl = "hash:route"; 206 + 207 + const route = signal(""); 208 + mount(element, { route }); 209 + 210 + expect(route.get()).toBe("/about"); 211 + }); 212 + 213 + it("updates hash when signal changes", () => { 214 + window.location.hash = ""; 215 + 216 + const element = document.createElement("div"); 217 + element.dataset.xUrl = "hash:route"; 218 + 219 + const route = signal(""); 220 + mount(element, { route }); 221 + 222 + route.set("/contact"); 223 + 224 + expect(window.location.hash).toBe("#/contact"); 225 + }); 226 + 227 + it("clears hash when signal is empty", () => { 228 + window.location.hash = "#/page"; 229 + 230 + const element = document.createElement("div"); 231 + element.dataset.xUrl = "hash:route"; 232 + 233 + const route = signal(""); 234 + mount(element, { route }); 235 + 236 + route.set(""); 237 + 238 + expect(window.location.hash).toBe(""); 239 + }); 240 + 241 + it("handles hashchange events", () => { 242 + window.location.hash = "#/home"; 243 + 244 + const element = document.createElement("div"); 245 + element.dataset.xUrl = "hash:route"; 246 + 247 + const route = signal(""); 248 + mount(element, { route }); 249 + 250 + expect(route.get()).toBe("/home"); 251 + 252 + window.location.hash = "#/settings"; 253 + window.dispatchEvent(new Event("hashchange")); 254 + 255 + expect(route.get()).toBe("/settings"); 256 + }); 257 + 258 + it("cleans up hashchange listener on unmount", () => { 259 + window.location.hash = "#/page1"; 260 + 261 + const element = document.createElement("div"); 262 + element.dataset.xUrl = "hash:route"; 263 + 264 + const route = signal(""); 265 + const cleanup = mount(element, { route }); 266 + 267 + expect(route.get()).toBe("/page1"); 268 + 269 + cleanup(); 270 + 271 + window.location.hash = "#/page2"; 272 + window.dispatchEvent(new Event("hashchange")); 273 + 274 + expect(route.get()).toBe("/page1"); 275 + }); 276 + }); 277 + 278 + describe("error handling", () => { 279 + it("logs error for invalid binding format", () => { 280 + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 281 + const element = document.createElement("div"); 282 + element.dataset.xUrl = "invalidformat"; 283 + 284 + mount(element, {}); 285 + 286 + expect(errorSpy).toHaveBeenCalledWith( 287 + expect.stringContaining("Invalid url binding"), 288 + ); 289 + 290 + errorSpy.mockRestore(); 291 + }); 292 + 293 + it("logs error for unknown url mode", () => { 294 + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 295 + const element = document.createElement("div"); 296 + element.dataset.xUrl = "unknown:signal"; 297 + 298 + mount(element, {}); 299 + 300 + expect(errorSpy).toHaveBeenCalledWith( 301 + expect.stringContaining('Unknown url mode: "unknown"'), 302 + ); 303 + 304 + errorSpy.mockRestore(); 305 + }); 306 + 307 + it("logs error when signal not found", () => { 308 + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 309 + const element = document.createElement("div"); 310 + element.dataset.xUrl = "read:nonexistent"; 311 + 312 + mount(element, {}); 313 + 314 + expect(errorSpy).toHaveBeenCalledWith( 315 + expect.stringContaining('Signal "nonexistent" not found'), 316 + ); 317 + 318 + errorSpy.mockRestore(); 319 + }); 320 + }); 321 + });
+7 -1
vite.config.ts
··· 5 5 environment: "jsdom", 6 6 setupFiles: "./test/setupTests.ts", 7 7 globals: true, 8 - coverage: { provider: "v8", thresholds: { "perFile": true, functions: 50, branches: 50, autoUpdate: true } }, 8 + exclude: ["**/node_modules/**", "**/dist/**", "**/cli/tests/**"], 9 + coverage: { 10 + provider: "v8", 11 + thresholds: { functions: 50, branches: 50 }, 12 + include: ["**/src/**"], 13 + exclude: ["**/cli/src/**"], 14 + }, 9 15 }, 10 16 });