···11+/**
22+ * Binder system for mounting and managing Volt.js bindings
33+ */
44+55+import { 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+}
2222+2323+/**
2424+ * Mount Volt.js on a root element and its descendants.
2525+ * Binds all data-x-* attributes to the provided scope.
2626+ * Returns a cleanup function to unmount and dispose all bindings.
2727+ *
2828+ * @param root - Root element to mount on
2929+ * @param scope - Scope object containing signals and data
3030+ * @returns Cleanup function to unmount
3131+ */
3232+export function mount(root: Element, scope: Scope): CleanupFunction {
3333+ const elements = walkDOM(root);
3434+ const allCleanups: CleanupFunction[] = [];
3535+3636+ for (const element of elements) {
3737+ const attributes = getVoltAttributes(element);
3838+ const context: BindingContext = { element, scope, cleanups: [] };
3939+4040+ for (const [name, value] of attributes) {
4141+ bindAttribute(context, name, value);
4242+ }
4343+4444+ allCleanups.push(...context.cleanups);
4545+ }
4646+4747+ return () => {
4848+ for (const cleanup of allCleanups) {
4949+ try {
5050+ cleanup();
5151+ } catch (error) {
5252+ console.error("Error during unmount:", error);
5353+ }
5454+ }
5555+ };
5656+}
5757+5858+/**
5959+ * Bind a single data-x-* attribute to an element.
6060+ * Routes to the appropriate binding handler.
6161+ *
6262+ * @param context - Binding context
6363+ * @param name - Attribute name (without data-x- prefix)
6464+ * @param value - Attribute value (expression)
6565+ */
6666+function bindAttribute(context: BindingContext, name: string, value: string): void {
6767+ switch (name) {
6868+ case "text": {
6969+ bindText(context, value);
7070+ break;
7171+ }
7272+ case "html": {
7373+ bindHTML(context, value);
7474+ break;
7575+ }
7676+ case "class": {
7777+ bindClass(context, value);
7878+ break;
7979+ }
8080+ default: {
8181+ console.warn(`Unknown binding: data-x-${name}`);
8282+ }
8383+ }
8484+}
8585+8686+/**
8787+ * Bind data-x-text to update element's text content.
8888+ * Subscribes to signals in the expression and updates on change.
8989+ *
9090+ * @param context - Binding context
9191+ * @param expression - Expression to evaluate
9292+ */
9393+function bindText(context: BindingContext, expression: string): void {
9494+ const update = () => {
9595+ const value = evaluate(expression, context.scope);
9696+ setText(context.element, value);
9797+ };
9898+9999+ update();
100100+101101+ const signal = findSignalInScope(context.scope, expression);
102102+ if (signal) {
103103+ const unsubscribe = signal.subscribe(update);
104104+ context.cleanups.push(unsubscribe);
105105+ }
106106+}
107107+108108+/**
109109+ * Bind data-x-html to update element's HTML content.
110110+ * Subscribes to signals in the expression and updates on change.
111111+ *
112112+ * @param context - Binding context
113113+ * @param expression - Expression to evaluate
114114+ */
115115+function bindHTML(context: BindingContext, expression: string): void {
116116+ const update = () => {
117117+ const value = evaluate(expression, context.scope);
118118+ setHTML(context.element, String(value ?? ""));
119119+ };
120120+121121+ update();
122122+123123+ const signal = findSignalInScope(context.scope, expression);
124124+ if (signal) {
125125+ const unsubscribe = signal.subscribe(update);
126126+ context.cleanups.push(unsubscribe);
127127+ }
128128+}
129129+130130+/**
131131+ * Bind data-x-class to toggle CSS classes.
132132+ * Supports both string and object notation.
133133+ * Subscribes to signals in the expression and updates on change.
134134+ *
135135+ * @param context - Binding context
136136+ * @param expression - Expression to evaluate
137137+ */
138138+function bindClass(context: BindingContext, expression: string): void {
139139+ let previousClasses = new Map<string, boolean>();
140140+141141+ const update = () => {
142142+ const value = evaluate(expression, context.scope);
143143+ const classes = parseClassBinding(value);
144144+145145+ for (const [className] of previousClasses) {
146146+ if (!classes.has(className)) {
147147+ toggleClass(context.element, className, false);
148148+ }
149149+ }
150150+151151+ for (const [className, shouldAdd] of classes) {
152152+ toggleClass(context.element, className, shouldAdd);
153153+ }
154154+155155+ previousClasses = classes;
156156+ };
157157+158158+ update();
159159+160160+ const signal = findSignalInScope(context.scope, expression);
161161+ if (signal) {
162162+ const unsubscribe = signal.subscribe(update);
163163+ context.cleanups.push(unsubscribe);
164164+ }
165165+}
166166+167167+/**
168168+ * Find a signal in the scope by resolving a simple property path.
169169+ * Returns the signal if found, otherwise undefined.
170170+ *
171171+ * @param scope - Scope object
172172+ * @param path - Property path (e.g., "count" or "user.name")
173173+ * @returns Signal if found, undefined otherwise
174174+ */
175175+function findSignalInScope(scope: Scope, path: string): Signal<unknown> | undefined {
176176+ const trimmed = path.trim();
177177+ const parts = trimmed.split(".");
178178+ let current: unknown = scope;
179179+180180+ for (const part of parts) {
181181+ if (current === null || current === undefined) {
182182+ return undefined;
183183+ }
184184+185185+ if (typeof current === "object" && part in (current as Record<string, unknown>)) {
186186+ current = (current as Record<string, unknown>)[part];
187187+ } else {
188188+ return undefined;
189189+ }
190190+ }
191191+192192+ if (
193193+ typeof current === "object" && current !== null && "get" in current && "set" in current && "subscribe" in current
194194+ ) {
195195+ return current as Signal<unknown>;
196196+ }
197197+198198+ return undefined;
199199+}
+22
src/core/evaluator.ts
···5151/**
5252 * Resolve a property path in a scope object.
5353 * Supports nested property access like "user.profile.name".
5454+ * Automatically unwraps signals by calling .get().
5455 *
5556 * @param path - The property path (e.g., "user.name")
5657 * @param scope - The scope object
···7273 }
7374 }
74757676+ if (isSignal(current)) {
7777+ return current.get();
7878+ }
7979+7580 return current;
7681}
8282+8383+/**
8484+ * Check if a value is a Signal.
8585+ *
8686+ * @param value - Value to check
8787+ * @returns true if the value is a Signal
8888+ */
8989+function 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+ );
9898+}
+1
src/index.ts
···55 */
6677export { signal, type Signal } from "./core/signal";
88+export { mount } from "./core/binder";