+2
-18
cli/eslint.config.js
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+9
src/plugins/index.ts
+222
src/plugins/persist.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
});