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