a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
1# VoltX Plugin System Spec 2 3## Overview 4 5The plugin system enables extending the framework with custom `data-volt-*` attribute bindings. 6 7Plugins follow the same binding patterns as core bindings (text, html, class, events) but can implement specialized behaviors like persistence, scrolling, and URL synchronization. 8 9## Design Goals 10 11### Extensibility 12 13Plugins can access the full binding context including the DOM element, reactive scope, signal utilities, and cleanup registration. 14 15### Explicit Opt-In 16 17Built-in plugins require explicit registration to keep the core bundle minimal. Applications only load the functionality they use. 18 19### Simplicity 20 21Plugin API mirrors the internal binding handler signature. Developers who end up familiar with Volt internals can easily create plugins. 22 23### Consistency 24 25Plugins should integrate seamlessly with the mount/unmount lifecycle, cleanup system, and reactive primitives. 26 27## Plugin API 28 29### Registration 30 31Plugins are registered using the `registerPlugin()` function: 32 33```ts 34registerPlugin(name: string, handler: PluginHandler): void 35``` 36 37The plugin name becomes the `data-volt-*` attribute suffix. For example, registering a plugin named `"tooltip"` enables `data-volt-tooltip` attributes. 38 39### Plugin Handler 40 41Plugin handlers receive a context object and the attribute value: 42 43```ts 44type PluginHandler = (context: PluginContext, value: string) => void 45``` 46 47The handler should: 48 491. Parse the attribute value 502. Set up bindings and subscriptions 513. Register cleanup functions for unmount 52 53### PluginContext 54 55The context object provides: 56 57```ts 58interface PluginContext { 59 element: Element; // The bound DOM element 60 scope: Scope; // Reactive scope with signals 61 addCleanup(fn: CleanupFunction): void; // Register cleanup 62 findSignal(path: string): Signal | undefined; // Locate signals by path 63 evaluate(expression: string): unknown; // Evaluate expressions 64} 65``` 66 67### Example: Custom Tooltip Plugin 68 69```ts 70import { registerPlugin } from 'voltx.js'; 71 72registerPlugin('tooltip', (context, value) => { 73 const tooltip = document.createElement('div'); 74 tooltip.className = 'tooltip'; 75 tooltip.textContent = context.evaluate(value); 76 77 const show = () => document.body.appendChild(tooltip); 78 const hide = () => tooltip.remove(); 79 80 context.element.addEventListener('mouseenter', show); 81 context.element.addEventListener('mouseleave', hide); 82 83 context.addCleanup(() => { 84 hide(); 85 context.element.removeEventListener('mouseenter', show); 86 context.element.removeEventListener('mouseleave', hide); 87 }); 88 89 const signal = context.findSignal(value); 90 if (signal) { 91 const unsubscribe = signal.subscribe((newValue) => { 92 tooltip.textContent = String(newValue); 93 }); 94 context.addCleanup(unsubscribe); 95 } 96}); 97``` 98 99## Built-in Plugins 100 101VoltX.js ships with three built-in plugins that must be explicitly registered. 102 103### data-volt-persist 104 105Synchronizes signal values with persistent storage (`localStorage`, `sessionStorage`, `IndexedDB`). 106 107**Syntax:** 108 109```html 110<input data-volt-persist="signalName:storageType" /> 111``` 112 113**Storage Types:** 114 115- `local` - localStorage (persistent across sessions) 116- `session` - sessionStorage (cleared on tab close) 117- `indexeddb` - IndexedDB (large datasets, async) 118- Custom adapters via `registerStorageAdapter()` 119 120**Behavior:** 121 1221. On mount: Load persisted value into signal (if exists) 1232. On signal change: Persist new value to storage 1243. On unmount: Clean up storage listeners 125 126**Examples:** 127 128```html 129<!-- Persist counter to localStorage --> 130<div data-volt-text="count" data-volt-persist="count:local"></div> 131 132<!-- Persist form state to sessionStorage --> 133<input data-volt-on-input="updateForm" data-volt-persist="formData:session" /> 134 135<!-- Persist large dataset to IndexedDB --> 136<div data-volt-persist="userData:indexeddb"></div> 137``` 138 139**Custom Storage Adapters:** 140 141```ts 142interface StorageAdapter { 143 get(key: string): Promise<unknown> | unknown; 144 set(key: string, value: unknown): Promise<void> | void; 145 remove(key: string): Promise<void> | void; 146} 147 148registerStorageAdapter('custom', { 149 async get(key) { /* ... */ }, 150 async set(key, value) { /* ... */ }, 151 async remove(key) { /* ... */ } 152}); 153``` 154 155### data-volt-scroll 156 157Manages scroll behavior including position restoration, programmatic scrolling, scroll spy, and smooth scrolling. 158 159**Syntax:** 160 161```html 162<!-- Scroll position restoration --> 163<div data-volt-scroll="restore:position"></div> 164 165<!-- Scroll to element when signal changes --> 166<div data-volt-scroll="scrollTo:targetId"></div> 167 168<!-- Scroll spy (updates signal when in viewport) --> 169<div data-volt-scroll="spy:isVisible"></div> 170 171<!-- Smooth scroll behavior --> 172<div data-volt-scroll="smooth:true"></div> 173``` 174 175**Behaviors:** 176 177**Position Restoration:** 178 179```html 180<div id="content" data-volt-scroll="restore:scrollPos"> 181 <!-- scroll position saved on scroll, restored on mount --> 182</div> 183``` 184 185Saves scroll position to the specified signal and restores on mount. 186 187**Scroll-To:** 188 189```html 190<button data-volt-on-click="scrollToSection.set('section2')">Go to Section 2</button> 191<div id="section2" data-volt-scroll="scrollTo:scrollToSection"></div> 192``` 193 194Scrolls to element when the specified signal changes to match element's ID or selector. 195 196**Scroll Spy:** 197 198```html 199<nav> 200 <a data-volt-class="{ active: section1Visible }">Section 1</a> 201 <a data-volt-class="{ active: section2Visible }">Section 2</a> 202</nav> 203<div data-volt-scroll="spy:section1Visible"></div> 204<div data-volt-scroll="spy:section2Visible"></div> 205``` 206 207Updates signal with boolean visibility state using Intersection Observer. 208 209**Smooth Scrolling:** 210 211```html 212<div data-volt-scroll="smooth:behavior"></div> 213``` 214 215Enables smooth scrolling with configurable behavior from signal. 216 217### data-volt-url 218 219Synchronizes signal values with URL parameters and hash-based routing. 220 221**Syntax:** 222 223```html 224<!-- One-way: Read URL param into signal on mount --> 225<input data-volt-url="read:searchQuery" /> 226 227<!-- Bidirectional: Keep URL and signal in sync --> 228<input data-volt-url="sync:filter" /> 229 230<!-- Hash-based routing --> 231<div data-volt-url="hash:currentRoute"></div> 232``` 233 234**Behaviors:** 235 236**Read URL Parameters:** 237 238```html 239<!-- Initialize signal from ?tab=profile --> 240<div data-volt-url="read:tab"></div> 241``` 242 243Reads URL parameter on mount and sets signal value. Signal changes do not update URL. 244 245**Bidirectional Sync:** 246 247```html 248<!-- Keep ?search=query in sync with searchQuery signal --> 249<input data-volt-on-input="handleSearch" data-volt-url="sync:searchQuery" /> 250``` 251 252You can also use the shorthand attribute form where the signal name is encoded in the attribute suffix: 253 254```html 255<!-- Equivalent to data-volt-url="sync:searchQuery" --> 256<input data-volt-url:searchQuery="query" /> 257``` 258 259Changes to signal update URL parameter, changes to URL update signal. Uses History API for clean URLs. 260 261**Hash Routing:** 262 263```html 264<!-- Sync with #/page/about --> 265<div data-volt-url="hash:route"></div> 266<div data-volt-text="route === '/page/about' ? 'About Page' : 'Home'"></div> 267``` 268 269Keeps hash portion of URL in sync with signal. Useful for client-side routing. 270 271**Notes:** 272 273- Uses History API (`pushState`/`replaceState`) for param sync 274- Listens to `popstate` for browser back/forward 275- Debounces URL updates to avoid excessive history entries 276- Automatically serializes/deserializes values (strings, numbers, booleans) 277- Accepts `data-volt-url="mode:signal"` or `data-volt-url:signal="mode"` forms 278- Supports `query`, `hash`, and `history` mode aliases in shorthand attributes (e.g., `data-volt-url:filter="query"`) 279 280## Implementation 281 282### Integration 283 284The binder system checks the plugin registry before falling through to unknown attribute warnings 285 286### Context 287 288The binder creates a PluginContext from BindingContext: 289 290```ts 291function createPluginContext(bindingContext: BindingContext): PluginContext { 292 return { 293 element: bindingContext.element, 294 scope: bindingContext.scope, 295 addCleanup: (fn) => bindingContext.cleanups.push(fn), 296 findSignal: (path) => findSignalInScope(bindingContext.scope, path), 297 evaluate: (expr) => evaluate(expr, bindingContext.scope) 298 }; 299} 300``` 301 302### Module Structure 303 304```sh 305src/ 306 core/ 307 plugin.ts # Plugin registry and API 308 binder.ts # Modified to integrate plugins 309 plugins/ 310 persist.ts # Persistence plugin 311 scroll.ts # Scroll behavior plugin 312 url.ts # URL synchronization plugin 313 index.ts # Exports registerPlugin and built-in plugins 314``` 315 316## Bundle Size Considerations 317 318With explicit registration, applications control their bundle size: 319 320- Core framework: ~15 KB gzipped (no plugins) 321- Each plugin: ~1-3 KB gzipped 322- Applications import only what they use 323- Tree-shaking eliminates unused plugins 324 325Example bundle breakdown: 326 327```sh 328volt/core : 15 KB 329volt/plugins/persist : 2 KB 330volt/plugins/scroll : 2.5 KB 331volt/plugins/url : 1.5 KB 332-------------------------------- 333Total (all plugins) : 21 KB 334``` 335 336## Extension Points 337 338Future plugin capabilities: 339 340- Lifecycle hooks (beforeMount, afterMount, beforeUnmount) 341- Plugin dependencies and composition 342- Plugin configuration API 343- Async plugin initialization 344- Plugin registry