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