···22 * Binder system for mounting and managing Volt.js bindings
33 */
4455+import type { BindingContext, CleanupFunction, PluginContext, Scope, Signal } from "../types/volt";
56import { getVoltAttributes, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom";
66-import { evaluate, type Scope } from "./evaluator";
77-import type { Signal } from "./signal";
88-99-/**
1010- * Cleanup function returned by binding handlers
1111- */
1212-type CleanupFunction = () => void;
1313-1414-/**
1515- * Context object available to all bindings
1616- */
1717-interface BindingContext {
1818- element: Element;
1919- scope: Scope;
2020- cleanups: CleanupFunction[];
2121-}
77+import { evaluate } from "./evaluator";
88+import { getPlugin } from "./plugin";
2292310/**
2411 * Mount Volt.js on a root element and its descendants.
···8471 break;
8572 }
8673 default: {
8787- console.warn(`Unknown binding: data-x-${name}`);
7474+ const plugin = getPlugin(name);
7575+ if (plugin) {
7676+ const pluginContext = createPluginContext(context);
7777+ try {
7878+ plugin(pluginContext, value);
7979+ } catch (error) {
8080+ console.error(`Error in plugin "${name}":`, error);
8181+ }
8282+ } else {
8383+ console.warn(`Unknown binding: data-x-${name}`);
8484+ }
8885 }
8986 }
9087}
···232229233230 return undefined;
234231}
232232+233233+/**
234234+ * Create a plugin context from a binding context.
235235+ * Provides the plugin with access to utilities and cleanup registration.
236236+ *
237237+ * @param bindingContext - Internal binding context
238238+ * @returns PluginContext for the plugin handler
239239+ */
240240+function createPluginContext(bindingContext: BindingContext): PluginContext {
241241+ return {
242242+ element: bindingContext.element,
243243+ scope: bindingContext.scope,
244244+ addCleanup: (fn) => {
245245+ bindingContext.cleanups.push(fn);
246246+ },
247247+ findSignal: (path) => findSignalInScope(bindingContext.scope, path),
248248+ evaluate: (expression) => evaluate(expression, bindingContext.scope),
249249+ };
250250+}
+7-9
src/core/evaluator.ts
···22 * Safe expression evaluation of simple expressions without using eval() for bindings
33 */
4455-export type Scope = Record<string, unknown>;
55+import type { Scope } from "../types/volt";
6677/**
88 * Evaluate a simple expression against a scope object.
···8787 * @returns true if the value is a Signal
8888 */
8989function isSignal(value: unknown): value is { get: () => unknown } {
9090- return (
9191- typeof value === "object" &&
9292- value !== null &&
9393- "get" in value &&
9494- "set" in value &&
9595- "subscribe" in value &&
9696- typeof value.get === "function"
9797- );
9090+ return (typeof value === "object"
9191+ && value !== null
9292+ && "get" in value
9393+ && "set" in value
9494+ && "subscribe" in value
9595+ && typeof value.get === "function");
9896}
+81
src/core/plugin.ts
···11+/**
22+ * Plugin system for extending Volt.js with custom bindings
33+ */
44+55+import type { PluginHandler } from "../types/volt";
66+77+const pluginRegistry = new Map<string, PluginHandler>();
88+99+/**
1010+ * Register a custom plugin with a given name.
1111+ * Plugins extend Volt.js with custom data-x-* attribute bindings.
1212+ *
1313+ * @param name - Plugin name (will be used as data-x-{name})
1414+ * @param handler - Plugin handler function
1515+ *
1616+ * @example
1717+ * registerPlugin('tooltip', (context, value) => {
1818+ * const tooltip = document.createElement('div');
1919+ * tooltip.className = 'tooltip';
2020+ * tooltip.textContent = value;
2121+ * context.element.addEventListener('mouseenter', () => {
2222+ * document.body.appendChild(tooltip);
2323+ * });
2424+ * context.element.addEventListener('mouseleave', () => {
2525+ * tooltip.remove();
2626+ * });
2727+ * context.addCleanup(() => tooltip.remove());
2828+ * });
2929+ */
3030+export function registerPlugin(name: string, handler: PluginHandler): void {
3131+ if (pluginRegistry.has(name)) {
3232+ console.warn(`Plugin "${name}" is already registered. Overwriting.`);
3333+ }
3434+ pluginRegistry.set(name, handler);
3535+}
3636+3737+/**
3838+ * Get a plugin handler by name.
3939+ *
4040+ * @param name - Plugin name
4141+ * @returns Plugin handler function or undefined
4242+ */
4343+export function getPlugin(name: string): PluginHandler | undefined {
4444+ return pluginRegistry.get(name);
4545+}
4646+4747+/**
4848+ * Check if a plugin is registered.
4949+ *
5050+ * @param name - Plugin name
5151+ * @returns true if the plugin is registered
5252+ */
5353+export function hasPlugin(name: string): boolean {
5454+ return pluginRegistry.has(name);
5555+}
5656+5757+/**
5858+ * Unregister a plugin by name.
5959+ *
6060+ * @param name - Plugin name
6161+ * @returns true if the plugin was unregistered, false if it wasn't registered
6262+ */
6363+export function unregisterPlugin(name: string): boolean {
6464+ return pluginRegistry.delete(name);
6565+}
6666+6767+/**
6868+ * Get all registered plugin names.
6969+ *
7070+ * @returns Array of registered plugin names
7171+ */
7272+export function getRegisteredPlugins(): string[] {
7373+ return [...pluginRegistry.keys()];
7474+}
7575+7676+/**
7777+ * Clear all registered plugins.
7878+ */
7979+export function clearPlugins(): void {
8080+ pluginRegistry.clear();
8181+}
+1-40
src/core/signal.ts
···11-/**
22- * A reactive primitive that notifies subscribers when its value changes.
33- */
44-export interface Signal<T> {
55- /**
66- * Get the current value of the signal.
77- */
88- get(): T;
99-1010- /**
1111- * Update the signal's value.
1212- * If the new value differs from the current value, subscribers will be notified.
1313- */
1414- set(value: T): void;
1515-1616- /**
1717- * Subscribe to changes in the signal's value.
1818- * The callback is invoked with the new value whenever it changes.
1919- * Returns an unsubscribe function to remove the subscription.
2020- */
2121- subscribe(callback: (value: T) => void): () => void;
2222-}
2323-2424-/**
2525- * A computed signal that derives its value from other signals.
2626- */
2727-export interface ComputedSignal<T> {
2828- /**
2929- * Get the current computed value.
3030- */
3131- get(): T;
3232-3333- /**
3434- * Subscribe to changes in the computed value.
3535- * Returns an unsubscribe function to remove the subscription.
3636- */
3737- subscribe(callback: (value: T) => void): () => void;
3838-}
11+import type { ComputedSignal, Signal } from "../types/volt";
392403/**
414 * Creates a new signal with the given initial value.
4242- * Signals are reactive primitives that automatically notify subscribers when changed.
435 *
446 * @param initialValue - The initial value of the signal
457 * @returns A Signal object with get, set, and subscribe methods
···148110149111/**
150112 * Creates a side effect that runs when dependencies change.
151151- * Effects run immediately on creation and whenever dependencies update.
152113 *
153114 * @param effectFunction - Function to run as a side effect
154115 * @param dependencies - Array of signals this effect depends on
+3-1
src/index.ts
···55 */
6677export { mount } from "./core/binder";
88-export { computed, type ComputedSignal, effect, type Signal, signal } from "./core/signal";
88+export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "./core/plugin";
99+export { computed, effect, signal } from "./core/signal";
1010+export type { ComputedSignal, PluginContext, PluginHandler, Signal } from "./types/volt";
···11+/**
22+ * Scroll plugin for managing scroll behavior
33+ * Supports position restoration, scroll-to, scroll spy, and smooth scrolling
44+ */
55+66+import type { PluginContext, Signal } from "../types/volt";
77+88+/**
99+ * Scroll plugin handler.
1010+ * Manages various scroll-related behaviors.
1111+ *
1212+ * Syntax: data-x-scroll="mode:signalPath"
1313+ * Modes:
1414+ * - restore:signalPath - Save/restore scroll position
1515+ * - scrollTo:signalPath - Scroll to element when signal changes
1616+ * - spy:signalPath - Update signal when element is visible
1717+ * - smooth:signalPath - Enable smooth scrolling behavior
1818+ */
1919+export function scrollPlugin(context: PluginContext, value: string): void {
2020+ const parts = value.split(":");
2121+ if (parts.length !== 2) {
2222+ console.error(`Invalid scroll binding: "${value}". Expected format: "mode:signalPath"`);
2323+ return;
2424+ }
2525+2626+ const [mode, signalPath] = parts.map((p) => p.trim());
2727+2828+ switch (mode) {
2929+ case "restore": {
3030+ handleScrollRestore(context, signalPath);
3131+ break;
3232+ }
3333+ case "scrollTo": {
3434+ handleScrollTo(context, signalPath);
3535+ break;
3636+ }
3737+ case "spy": {
3838+ handleScrollSpy(context, signalPath);
3939+ break;
4040+ }
4141+ case "smooth": {
4242+ handleSmoothScroll(context, signalPath);
4343+ break;
4444+ }
4545+ default: {
4646+ console.error(`Unknown scroll mode: "${mode}"`);
4747+ }
4848+ }
4949+}
5050+5151+/**
5252+ * Save and restore scroll position.
5353+ * Saves current scroll position to signal on scroll events.
5454+ * Restores scroll position from signal on mount.
5555+ */
5656+function handleScrollRestore(context: PluginContext, signalPath: string): void {
5757+ const signal = context.findSignal(signalPath);
5858+ if (!signal) {
5959+ console.error(`Signal "${signalPath}" not found for scroll restore`);
6060+ return;
6161+ }
6262+6363+ const element = context.element as HTMLElement;
6464+ const savedPosition = signal.get();
6565+ if (typeof savedPosition === "number") {
6666+ element.scrollTop = savedPosition;
6767+ }
6868+6969+ const savePosition = () => {
7070+ (signal as Signal<number>).set(element.scrollTop);
7171+ };
7272+7373+ element.addEventListener("scroll", savePosition, { passive: true });
7474+7575+ context.addCleanup(() => {
7676+ element.removeEventListener("scroll", savePosition);
7777+ });
7878+}
7979+8080+/**
8181+ * Scroll to element when signal value matches element's ID or selector.
8282+ * Listens for changes to the target signal and scrolls to this element.
8383+ */
8484+function handleScrollTo(context: PluginContext, signalPath: string): void {
8585+ const signal = context.findSignal(signalPath);
8686+ if (!signal) {
8787+ console.error(`Signal "${signalPath}" not found for scrollTo`);
8888+ return;
8989+ }
9090+9191+ const element = context.element as HTMLElement;
9292+ const elementId = element.id;
9393+9494+ const checkAndScroll = (target: unknown) => {
9595+ if (target === elementId || target === `#${elementId}`) {
9696+ element.scrollIntoView({ behavior: "smooth", block: "start" });
9797+ }
9898+ };
9999+100100+ checkAndScroll(signal.get());
101101+102102+ const unsubscribe = signal.subscribe(checkAndScroll);
103103+ context.addCleanup(unsubscribe);
104104+}
105105+106106+/**
107107+ * Update signal when element enters or exits viewport.
108108+ * Uses Intersection Observer to track visibility.
109109+ */
110110+function handleScrollSpy(context: PluginContext, signalPath: string): void {
111111+ const signal = context.findSignal(signalPath);
112112+ if (!signal) {
113113+ console.error(`Signal "${signalPath}" not found for scroll spy`);
114114+ return;
115115+ }
116116+117117+ const element = context.element as HTMLElement;
118118+119119+ const observer = new IntersectionObserver((entries) => {
120120+ for (const entry of entries) {
121121+ if (entry.target === element) {
122122+ (signal as Signal<boolean>).set(entry.isIntersecting);
123123+ }
124124+ }
125125+ }, { threshold: 0.1 });
126126+127127+ observer.observe(element);
128128+129129+ context.addCleanup(() => {
130130+ observer.disconnect();
131131+ });
132132+}
133133+134134+/**
135135+ * Enable smooth scrolling behavior.
136136+ * Applies smooth scroll behavior based on signal value.
137137+ */
138138+function handleSmoothScroll(context: PluginContext, signalPath: string): void {
139139+ const signal = context.findSignal(signalPath);
140140+ if (!signal) {
141141+ console.error(`Signal "${signalPath}" not found for smooth scroll`);
142142+ return;
143143+ }
144144+145145+ const element = context.element as HTMLElement;
146146+147147+ const applyBehavior = (value: unknown) => {
148148+ if (value === true || value === "smooth") {
149149+ element.style.scrollBehavior = "smooth";
150150+ } else if (value === false || value === "auto") {
151151+ element.style.scrollBehavior = "auto";
152152+ }
153153+ };
154154+155155+ applyBehavior(signal.get());
156156+157157+ const unsubscribe = signal.subscribe(applyBehavior);
158158+159159+ context.addCleanup(() => {
160160+ unsubscribe();
161161+ element.style.scrollBehavior = "";
162162+ });
163163+}
+216
src/plugins/url.ts
···11+/**
22+ * URL plugin for synchronizing signals with URL parameters and hash routing
33+ * Supports one-way read, bidirectional sync, and hash-based routing
44+ */
55+66+import type { PluginContext, Signal } from "../types/volt";
77+88+/**
99+ * URL plugin handler.
1010+ * Synchronizes signal values with URL parameters and hash.
1111+ *
1212+ * Syntax: data-x-url="mode:signalPath"
1313+ * Modes:
1414+ * - read:signalPath - Read URL param into signal on mount (one-way)
1515+ * - sync:signalPath - Bidirectional sync between signal and URL param
1616+ * - hash:signalPath - Sync with hash portion for routing
1717+ */
1818+export function urlPlugin(context: PluginContext, value: string): void {
1919+ const parts = value.split(":");
2020+ if (parts.length !== 2) {
2121+ console.error(`Invalid url binding: "${value}". Expected format: "mode:signalPath"`);
2222+ return;
2323+ }
2424+2525+ const [mode, signalPath] = parts.map((p) => p.trim());
2626+2727+ switch (mode) {
2828+ case "read": {
2929+ handleUrlRead(context, signalPath);
3030+ break;
3131+ }
3232+ case "sync": {
3333+ handleUrlSync(context, signalPath);
3434+ break;
3535+ }
3636+ case "hash": {
3737+ handleHashRouting(context, signalPath);
3838+ break;
3939+ }
4040+ default: {
4141+ console.error(`Unknown url mode: "${mode}"`);
4242+ }
4343+ }
4444+}
4545+4646+/**
4747+ * Read URL parameter into signal on mount (one-way).
4848+ * Signal changes do not update URL.
4949+ */
5050+function handleUrlRead(context: PluginContext, signalPath: string): void {
5151+ const signal = context.findSignal(signalPath);
5252+ if (!signal) {
5353+ console.error(`Signal "${signalPath}" not found for url read`);
5454+ return;
5555+ }
5656+5757+ const params = new URLSearchParams(globalThis.location.search);
5858+ const paramValue = params.get(signalPath);
5959+6060+ if (paramValue !== null) {
6161+ (signal as Signal<unknown>).set(deserializeValue(paramValue));
6262+ }
6363+}
6464+6565+/**
6666+ * Bidirectional sync between signal and URL parameter.
6767+ * Changes to either the signal or URL update the other.
6868+ */
6969+function handleUrlSync(context: PluginContext, signalPath: string): void {
7070+ const signal = context.findSignal(signalPath);
7171+ if (!signal) {
7272+ console.error(`Signal "${signalPath}" not found for url sync`);
7373+ return;
7474+ }
7575+7676+ const params = new URLSearchParams(globalThis.location.search);
7777+ const paramValue = params.get(signalPath);
7878+ if (paramValue !== null) {
7979+ (signal as Signal<unknown>).set(deserializeValue(paramValue));
8080+ }
8181+8282+ let isUpdatingFromUrl = false;
8383+ let updateTimeout: number | undefined;
8484+8585+ const updateUrl = (value: unknown) => {
8686+ if (isUpdatingFromUrl) return;
8787+8888+ if (updateTimeout) {
8989+ clearTimeout(updateTimeout);
9090+ }
9191+9292+ updateTimeout = setTimeout(() => {
9393+ const params = new URLSearchParams(globalThis.location.search);
9494+ const serialized = serializeValue(value);
9595+9696+ if (serialized === null || serialized === "") {
9797+ params.delete(signalPath);
9898+ } else {
9999+ params.set(signalPath, serialized);
100100+ }
101101+102102+ const newSearch = params.toString();
103103+ const newUrl = newSearch ? `?${newSearch}` : globalThis.location.pathname;
104104+105105+ globalThis.history.pushState({}, "", newUrl);
106106+ }, 100) as unknown as number;
107107+ };
108108+109109+ const handlePopState = () => {
110110+ isUpdatingFromUrl = true;
111111+ const params = new URLSearchParams(globalThis.location.search);
112112+ const paramValue = params.get(signalPath);
113113+114114+ if (paramValue === null) {
115115+ (signal as Signal<unknown>).set("");
116116+ } else {
117117+ (signal as Signal<unknown>).set(deserializeValue(paramValue));
118118+ }
119119+ isUpdatingFromUrl = false;
120120+ };
121121+122122+ const unsubscribe = signal.subscribe(updateUrl);
123123+ globalThis.addEventListener("popstate", handlePopState);
124124+125125+ context.addCleanup(() => {
126126+ unsubscribe();
127127+ globalThis.removeEventListener("popstate", handlePopState);
128128+ if (updateTimeout) {
129129+ clearTimeout(updateTimeout);
130130+ }
131131+ });
132132+}
133133+134134+/**
135135+ * Sync signal with hash portion of URL for client-side routing.
136136+ * Bidirectional sync between signal and window.location.hash.
137137+ */
138138+function handleHashRouting(context: PluginContext, signalPath: string): void {
139139+ const signal = context.findSignal(signalPath);
140140+ if (!signal) {
141141+ console.error(`Signal "${signalPath}" not found for hash routing`);
142142+ return;
143143+ }
144144+145145+ const currentHash = globalThis.location.hash.slice(1);
146146+ if (currentHash) {
147147+ (signal as Signal<string>).set(currentHash);
148148+ }
149149+150150+ let isUpdatingFromHash = false;
151151+152152+ const updateHash = (value: unknown) => {
153153+ if (isUpdatingFromHash) return;
154154+155155+ const hashValue = String(value ?? "");
156156+ const newHash = hashValue ? `#${hashValue}` : "";
157157+158158+ if (globalThis.location.hash !== newHash) {
159159+ globalThis.history.pushState({}, "", newHash || globalThis.location.pathname);
160160+ }
161161+ };
162162+163163+ const handleHashChange = () => {
164164+ isUpdatingFromHash = true;
165165+ const currentHash = globalThis.location.hash.slice(1);
166166+ (signal as Signal<string>).set(currentHash);
167167+ isUpdatingFromHash = false;
168168+ };
169169+170170+ const unsubscribe = signal.subscribe(updateHash);
171171+ globalThis.addEventListener("hashchange", handleHashChange);
172172+173173+ context.addCleanup(() => {
174174+ unsubscribe();
175175+ globalThis.removeEventListener("hashchange", handleHashChange);
176176+ });
177177+}
178178+179179+/**
180180+ * Serialize a value for URL parameter storage.
181181+ *
182182+ * Handles strings, numbers, booleans, and No Value (null/undefined).
183183+ */
184184+function serializeValue(value: unknown): string {
185185+ if (value === null || value === undefined) {
186186+ return "";
187187+ }
188188+ if (typeof value === "string") {
189189+ return value;
190190+ }
191191+ if (typeof value === "number" || typeof value === "boolean") {
192192+ return String(value);
193193+ }
194194+ return JSON.stringify(value);
195195+}
196196+197197+/**
198198+ * Deserialize a URL parameter value by attempting to parse as JSON, falls back to string.
199199+ */
200200+function deserializeValue(value: string): unknown {
201201+ if (value === "true") return true;
202202+ if (value === "false") return false;
203203+ if (value === "null") return null;
204204+ if (value === "undefined") return undefined;
205205+206206+ const numberValue = Number(value);
207207+ if (!Number.isNaN(numberValue) && value !== "") {
208208+ return numberValue;
209209+ }
210210+211211+ try {
212212+ return JSON.parse(value);
213213+ } catch {
214214+ return value;
215215+ }
216216+}
+104
src/types/volt.d.ts
···11+export type CleanupFunction = () => void;
22+33+export type Scope = Record<string, unknown>;
44+55+/**
66+ * Context object available to all bindings
77+ */
88+export interface BindingContext {
99+ element: Element;
1010+ scope: Scope;
1111+ cleanups: CleanupFunction[];
1212+}
1313+1414+/**
1515+ * Context object provided to plugin handlers.
1616+ * Contains utilities and references for implementing custom bindings.
1717+ */
1818+export interface PluginContext {
1919+ /**
2020+ * The DOM element the plugin is bound to
2121+ */
2222+ element: Element;
2323+2424+ /**
2525+ * The scope object containing signals and data
2626+ */
2727+ scope: Scope;
2828+2929+ /**
3030+ * Register a cleanup function to be called on unmount.
3131+ * Plugins should use this to clean up subscriptions, event listeners, etc.
3232+ */
3333+ addCleanup(fn: CleanupFunction): void;
3434+3535+ /**
3636+ * Find a signal in the scope by property path.
3737+ * Returns undefined if not found or if the value is not a signal.
3838+ */
3939+ findSignal(path: string): Signal<unknown> | undefined;
4040+4141+ /**
4242+ * Evaluate an expression against the scope.
4343+ * Handles simple property paths, literals, and signal unwrapping.
4444+ */
4545+ evaluate(expression: string): unknown;
4646+}
4747+4848+/**
4949+ * Plugin handler function signature.
5050+ * Receives context and the attribute value, performs binding setup.
5151+ */
5252+export type PluginHandler = (context: PluginContext, value: string) => void;
5353+5454+/**
5555+ * A reactive primitive that notifies subscribers when its value changes.
5656+ */
5757+export interface Signal<T> {
5858+ /**
5959+ * Get the current value of the signal.
6060+ */
6161+ get(): T;
6262+6363+ /**
6464+ * Update the signal's value.
6565+ *
6666+ * If the new value differs from the current value, subscribers will be notified.
6767+ */
6868+ set(value: T): void;
6969+7070+ /**
7171+ * Subscribe to changes in the signal's value.
7272+ *
7373+ * The callback is invoked with the new value whenever it changes.
7474+ *
7575+ * Returns an unsubscribe function to remove the subscription.
7676+ */
7777+ subscribe(callback: (value: T) => void): () => void;
7878+}
7979+8080+/**
8181+ * A computed signal that derives its value from other signals.
8282+ */
8383+export interface ComputedSignal<T> {
8484+ /**
8585+ * Get the current computed value.
8686+ */
8787+ get(): T;
8888+8989+ /**
9090+ * Subscribe to changes in the computed value.
9191+ *
9292+ * Returns an unsubscribe function to remove the subscription.
9393+ */
9494+ subscribe(callback: (value: T) => void): () => void;
9595+}
9696+9797+/**
9898+ * Storage adapter interface for custom persistence backends
9999+ */
100100+export interface StorageAdapter {
101101+ get(key: string): Promise<unknown> | unknown;
102102+ set(key: string, value: unknown): Promise<void> | void;
103103+ remove(key: string): Promise<void> | void;
104104+}