···4455import type { BindingContext, CleanupFunction, PluginContext, Scope, Signal } from "../types/volt";
66import { getVoltAttributes, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom";
77-import { evaluate } from "./evaluator";
77+import { evaluate, extractDependencies, isSignal } from "./evaluator";
88import { getPlugin } from "./plugin";
991010/**
1111- * Mount Volt.js on a root element and its descendants and binds all data-x-* attributes to the provided scope.
1111+ * Mount Volt.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope.
1212 * Returns a cleanup function to unmount and dispose all bindings.
1313 *
1414 * @param root - Root element to mount on
···5050}
51515252/**
5353- * Bind a single data-x-* attribute to an element.
5353+ * Bind a single data-volt-* attribute to an element.
5454 * Routes to the appropriate binding handler.
5555 *
5656 * @param context - Binding context
5757- * @param name - Attribute name (without data-x- prefix)
5757+ * @param name - Attribute name (without data-volt- prefix)
5858 * @param value - Attribute value (expression)
5959 */
6060function bindAttribute(context: BindingContext, name: string, value: string): void {
···6464 return;
6565 }
66666767+ if (name.startsWith("bind:")) {
6868+ const attrName = name.slice(5);
6969+ bindAttr(context, attrName, value);
7070+ return;
7171+ }
7272+6773 switch (name) {
6874 case "text": {
6975 bindText(context, value);
···7581 }
7682 case "class": {
7783 bindClass(context, value);
8484+ break;
8585+ }
8686+ case "model": {
8787+ bindModel(context, value);
7888 break;
7989 }
8090 case "for": {
···91101 console.error(`Error in plugin "${name}":`, error);
92102 }
93103 } else {
9494- console.warn(`Unknown binding: data-x-${name}`);
104104+ console.warn(`Unknown binding: data-volt-${name}`);
95105 }
96106 }
97107 }
98108}
99109100110/**
101101- * Bind data-x-text to update element's text content.
111111+ * Bind data-volt-text to update element's text content.
102112 * Subscribes to signals in the expression and updates on change.
103113 *
104114 * @param context - Binding context
···120130}
121131122132/**
123123- * Bind data-x-html to update element's HTML content.
133133+ * Bind data-volt-html to update element's HTML content.
134134+ *
124135 * Subscribes to signals in the expression and updates on change.
125125- *
126126- * @param context - Binding context
127127- * @param expression - Expression to evaluate
128136 */
129137function bindHTML(context: BindingContext, expression: string): void {
130138 const update = () => {
···142150}
143151144152/**
145145- * Bind data-x-class to toggle CSS classes.
153153+ * Bind data-volt-class to toggle CSS classes.
146154 * Supports both string and object notation.
147155 * Subscribes to signals in the expression and updates on change.
148156 *
···179187}
180188181189/**
182182- * Bind data-x-on-* to attach event listeners.
190190+ * Bind data-volt-on-* to attach event listeners.
183191 * Provides $el and $event in the scope for the event handler.
184192 *
185193 * @param context - Binding context
···208216}
209217210218/**
211211- * Find a signal in the scope by resolving a simple property path.
212212- * Returns the signal if found, otherwise undefined.
219219+ * Bind data-volt-model for two-way data binding on form elements.
220220+ * Syncs the signal value with the input value bidirectionally.
213221 *
214214- * @param scope - Scope object
215215- * @param path - Property path (e.g., "count" or "user.name")
216216- * @returns Signal if found, undefined otherwise
222222+ * @param context - Binding context
223223+ * @param signalPath - Path to the signal in scope
224224+ */
225225+function bindModel(context: BindingContext, signalPath: string): void {
226226+ const signal = findSignalInScope(context.scope, signalPath);
227227+ if (!signal) {
228228+ console.error(`Signal "${signalPath}" not found for data-volt-model`);
229229+ return;
230230+ }
231231+232232+ const element = context.element as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
233233+ const type = element instanceof HTMLInputElement ? element.type : null;
234234+ const initialValue = signal.get();
235235+ setElementValue(element, initialValue, type);
236236+237237+ const unsubscribe = signal.subscribe(() => {
238238+ const value = signal.get();
239239+ setElementValue(element, value, type);
240240+ });
241241+ context.cleanups.push(unsubscribe);
242242+243243+ const eventName = type === "checkbox" || type === "radio" ? "change" : "input";
244244+245245+ const handler = () => {
246246+ const value = getElementValue(element, type);
247247+ (signal as Signal<unknown>).set(value);
248248+ };
249249+250250+ element.addEventListener(eventName, handler);
251251+ context.cleanups.push(() => {
252252+ element.removeEventListener(eventName, handler);
253253+ });
254254+}
255255+256256+/**
257257+ * Set element value based on type
258258+ */
259259+function setElementValue(
260260+ element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
261261+ value: unknown,
262262+ type: string | null,
263263+): void {
264264+ if (element instanceof HTMLInputElement) {
265265+ switch (type) {
266266+ case "checkbox": {
267267+ element.checked = Boolean(value);
268268+269269+ break;
270270+ }
271271+ case "radio": {
272272+ element.checked = element.value === String(value);
273273+ break;
274274+ }
275275+ case "number": {
276276+ element.value = String(value ?? "");
277277+ break;
278278+ }
279279+ default: {
280280+ element.value = String(value ?? "");
281281+ }
282282+ }
283283+ } else if (element instanceof HTMLSelectElement) {
284284+ element.value = String(value ?? "");
285285+ } else if (element instanceof HTMLTextAreaElement) {
286286+ element.value = String(value ?? "");
287287+ }
288288+}
289289+290290+/**
291291+ * Get element value based on type
292292+ */
293293+function getElementValue(
294294+ element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
295295+ type: string | null,
296296+): unknown {
297297+ if (element instanceof HTMLInputElement) {
298298+ if (type === "checkbox") {
299299+ return element.checked;
300300+ }
301301+ if (type === "number") {
302302+ return element.valueAsNumber;
303303+ }
304304+ return element.value;
305305+ }
306306+307307+ if (element instanceof HTMLSelectElement) {
308308+ return element.value;
309309+ }
310310+311311+ if (element instanceof HTMLTextAreaElement) {
312312+ return element.value;
313313+ }
314314+315315+ return "";
316316+}
317317+318318+/**
319319+ * Bind data-volt-bind:attr for generic attribute binding.
320320+ *
321321+ * Updates any HTML attribute reactively based on expression value.
322322+ */
323323+function bindAttr(context: BindingContext, attrName: string, expression: string): void {
324324+ const update = () => {
325325+ const value = evaluate(expression, context.scope);
326326+327327+ const booleanAttrs = new Set([
328328+ "disabled",
329329+ "checked",
330330+ "selected",
331331+ "readonly",
332332+ "required",
333333+ "multiple",
334334+ "autofocus",
335335+ "autoplay",
336336+ "controls",
337337+ "loop",
338338+ "muted",
339339+ ]);
340340+341341+ if (booleanAttrs.has(attrName)) {
342342+ if (value) {
343343+ context.element.setAttribute(attrName, "");
344344+ } else {
345345+ context.element.removeAttribute(attrName);
346346+ }
347347+ } else {
348348+ if (value === null || value === undefined || value === false) {
349349+ context.element.removeAttribute(attrName);
350350+ } else {
351351+ context.element.setAttribute(attrName, String(value));
352352+ }
353353+ }
354354+ };
355355+356356+ update();
357357+358358+ const dependencies = extractDependencies(expression, context.scope);
359359+ for (const dependency of dependencies) {
360360+ const unsubscribe = dependency.subscribe(update);
361361+ context.cleanups.push(unsubscribe);
362362+ }
363363+}
364364+365365+/**
366366+ * Find a signal in the scope by resolving a simple property path.
217367 */
218368function findSignalInScope(scope: Scope, path: string): Signal<unknown> | undefined {
219369 const trimmed = path.trim();
···232382 }
233383 }
234384235235- if (
236236- typeof current === "object"
237237- && current !== null
238238- && "get" in current
239239- && "subscribe" in current
240240- && typeof (current as { get: unknown }).get === "function"
241241- && typeof (current as { subscribe: unknown }).subscribe === "function"
242242- ) {
385385+ if (isSignal(current)) {
243386 return current as Signal<unknown>;
244387 }
245388···247390}
248391249392/**
250250- * Bind data-x-for to render a list of items.
393393+ * Bind data-volt-for to render a list of items.
251394 * Subscribes to array signal and re-renders when array changes.
252395 *
253396 * @param context - Binding context
···256399function bindFor(context: BindingContext, expression: string): void {
257400 const parsed = parseForExpression(expression);
258401 if (!parsed) {
259259- console.error(`Invalid data-x-for expression: "${expression}"`);
402402+ console.error(`Invalid data-volt-for expression: "${expression}"`);
260403 return;
261404 }
262405···265408 const parent = template.parentElement;
266409267410 if (!parent) {
268268- console.error("data-x-for element must have a parent");
411411+ console.error("data-volt-for element must have a parent");
269412 return;
270413 }
271414···294437295438 for (const [index, item] of arrayValue.entries()) {
296439 const clone = template.cloneNode(true) as Element;
297297- delete (clone as HTMLElement).dataset.xFor;
440440+ delete (clone as HTMLElement).dataset.voltFor;
298441299442 const itemScope: Scope = { ...context.scope, [itemName]: item };
300443 if (indexName) {
···325468}
326469327470/**
328328- * Bind data-x-if to conditionally render an element.
329329- * Subscribes to condition signal and shows/hides element when condition changes.
471471+ * Bind data-volt-if to conditionally render an element.
472472+ * Supports data-volt-else on the next sibling element.
473473+ * Subscribes to condition signal and shows/hides elements when condition changes.
330474 *
331475 * @param context - Binding context
332476 * @param expression - Expression to evaluate as condition
333477 */
334478function bindIf(context: BindingContext, expression: string): void {
335335- const template = context.element as HTMLElement;
336336- const parent = template.parentElement;
479479+ const ifTemplate = context.element as HTMLElement;
480480+ const parent = ifTemplate.parentElement;
337481338482 if (!parent) {
339339- console.error("data-x-if element must have a parent");
483483+ console.error("data-volt-if element must have a parent");
340484 return;
341485 }
342486487487+ let elseTemplate: HTMLElement | undefined;
488488+ let nextSibling = ifTemplate.nextElementSibling;
489489+490490+ while (nextSibling && nextSibling.nodeType !== 1) {
491491+ nextSibling = nextSibling.nextElementSibling;
492492+ }
493493+494494+ if (nextSibling && Object.hasOwn((nextSibling as HTMLElement).dataset, "voltElse")) {
495495+ elseTemplate = nextSibling as HTMLElement;
496496+ elseTemplate.remove();
497497+ }
498498+343499 const placeholder = document.createComment(`if: ${expression}`);
344344- template.before(placeholder);
345345- template.remove();
500500+ ifTemplate.before(placeholder);
501501+ ifTemplate.remove();
346502347503 let currentElement: Element | undefined;
348504 let currentCleanup: CleanupFunction | undefined;
505505+ let currentBranch: "if" | "else" | undefined;
349506350507 const render = () => {
351508 const condition = evaluate(expression, context.scope);
352509 const shouldShow = Boolean(condition);
353510354354- if (shouldShow && !currentElement) {
355355- currentElement = template.cloneNode(true) as Element;
356356- delete (currentElement as HTMLElement).dataset.xIf;
511511+ const targetBranch = shouldShow ? "if" : (elseTemplate ? "else" : undefined);
512512+513513+ if (targetBranch === currentBranch) {
514514+ return;
515515+ }
516516+517517+ if (currentCleanup) {
518518+ currentCleanup();
519519+ currentCleanup = undefined;
520520+ }
521521+ if (currentElement) {
522522+ currentElement.remove();
523523+ currentElement = undefined;
524524+ }
525525+526526+ if (targetBranch === "if") {
527527+ currentElement = ifTemplate.cloneNode(true) as Element;
528528+ delete (currentElement as HTMLElement).dataset.voltIf;
357529 currentCleanup = mount(currentElement, context.scope);
358530 placeholder.before(currentElement);
359359- } else if (!shouldShow && currentElement) {
360360- if (currentCleanup) {
361361- currentCleanup();
362362- }
363363- currentElement.remove();
364364- currentElement = undefined;
365365- currentCleanup = undefined;
531531+ currentBranch = "if";
532532+ } else if (targetBranch === "else" && elseTemplate) {
533533+ currentElement = elseTemplate.cloneNode(true) as Element;
534534+ delete (currentElement as HTMLElement).dataset.voltElse;
535535+ currentCleanup = mount(currentElement, context.scope);
536536+ placeholder.before(currentElement);
537537+ currentBranch = "else";
538538+ } else {
539539+ currentBranch = undefined;
366540 }
367541 };
368542···382556}
383557384558/**
385385- * Parse a data-x-for expression.
559559+ * Parse a data-volt-for expression
560560+ *
386561 * Supports: "item in items" or "(item, index) in items"
387387- *
388388- * @param expr - The for expression
389389- * @returns Parsed parts or undefined if invalid
390562 */
391563function parseForExpression(expr: string): { itemName: string; indexName?: string; arrayPath: string } | undefined {
392564 const trimmed = expr.trim();
···406578407579/**
408580 * Create a plugin context from a binding context.
409409- * Provides the plugin with access to utilities and cleanup registration.
410581 *
411411- * @param bindingContext - Internal binding context
412412- * @returns PluginContext for the plugin handler
582582+ * Provides the plugin with access to utilities and cleanup registration.
413583 */
414584function createPluginContext(bindingContext: BindingContext): PluginContext {
415585 return {
+123
src/core/charge.ts
···11+/**
22+ * Charge system (bootstrap) for auto-discovery and initialization of Volt roots
33+ *
44+ * Handles declarative state initialization via data-volt-state and data-volt-computed
55+ */
66+77+import type { ChargedRoot, ChargeResult, Scope } from "../types/volt";
88+import { mount } from "./binder";
99+import { evaluate, extractDependencies } from "./evaluator";
1010+import { computed, signal } from "./signal";
1111+1212+/**
1313+ * Discover and mount all Volt roots in the document.
1414+ * Parses data-volt-state for initial state and data-volt-computed for derived values.
1515+ *
1616+ * @param rootSelector - Selector for root elements (default: "[data-volt]")
1717+ * @returns ChargeResult containing mounted roots and cleanup function
1818+ *
1919+ * @example
2020+ * ```html
2121+ * <div data-volt data-volt-state='{"count": 0}' data-volt-computed:double="count * 2">
2222+ * <p data-volt-text="count"></p>
2323+ * <p data-volt-text="double"></p>
2424+ * </div>
2525+ * ```
2626+ *
2727+ * ```ts
2828+ * const { cleanup } = charge();
2929+ * // Later: cleanup() to unmount all
3030+ * ```
3131+ */
3232+export function charge(rootSelector = "[data-volt]"): ChargeResult {
3333+ const elements = document.querySelectorAll(rootSelector);
3434+ const chargedRoots: ChargedRoot[] = [];
3535+3636+ for (const element of elements) {
3737+ try {
3838+ const scope = createScopeFromElement(element);
3939+ const cleanup = mount(element, scope);
4040+4141+ chargedRoots.push({ element, scope, cleanup });
4242+ } catch (error) {
4343+ console.error("Error charging Volt root:", element, error);
4444+ }
4545+ }
4646+4747+ return {
4848+ roots: chargedRoots,
4949+ cleanup: () => {
5050+ for (const root of chargedRoots) {
5151+ try {
5252+ root.cleanup();
5353+ } catch (error) {
5454+ console.error("Error cleaning up Volt root:", root.element, error);
5555+ }
5656+ }
5757+ },
5858+ };
5959+}
6060+6161+/**
6262+ * Create a reactive scope from element's data-volt-state and data-volt-computed attributes
6363+ *
6464+ * @param element - The root element
6565+ * @returns Reactive scope object with signals
6666+ */
6767+function createScopeFromElement(element: Element): Scope {
6868+ const scope: Scope = {};
6969+7070+ const stateAttr = (element as HTMLElement).dataset.voltState;
7171+ if (stateAttr) {
7272+ try {
7373+ const stateData = JSON.parse(stateAttr);
7474+7575+ if (typeof stateData !== "object" || stateData === null || Array.isArray(stateData)) {
7676+ console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, element);
7777+ } else {
7878+ for (const [key, value] of Object.entries(stateData)) {
7979+ scope[key] = signal(value);
8080+ }
8181+ }
8282+ } catch (error) {
8383+ console.error("Failed to parse data-volt-state JSON:", stateAttr, error);
8484+ console.error("Element:", element);
8585+ }
8686+ }
8787+8888+ const computedAttrs = getComputedAttributes(element);
8989+ for (const [name, expression] of computedAttrs) {
9090+ try {
9191+ const dependencies = extractDependencies(expression, scope);
9292+9393+ scope[name] = computed(() => evaluate(expression, scope), dependencies);
9494+ } catch (error) {
9595+ console.error(`Failed to create computed "${name}" with expression "${expression}":`, error);
9696+ }
9797+ }
9898+9999+ return scope;
100100+}
101101+102102+/**
103103+ * Get all data-volt-computed:name attributes from an element.
104104+ *
105105+ * Converts kebab-case names to camelCase to match JS conventions.
106106+ */
107107+function getComputedAttributes(element: Element): Map<string, string> {
108108+ const computed = new Map<string, string>();
109109+110110+ for (const attr of element.attributes) {
111111+ if (attr.name.startsWith("data-volt-computed:")) {
112112+ const name = attr.name.slice("data-volt-computed:".length);
113113+ const camelName = kebabToCamel(name);
114114+ computed.set(camelName, attr.value);
115115+ }
116116+ }
117117+118118+ return computed;
119119+}
120120+121121+function kebabToCamel(str: string): string {
122122+ return str.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
123123+}
+27-15
src/core/dom.ts
···33 */
4455/**
66- * Walk the DOM tree and collect all elements with data-x-* attributes.
77- * Returns elements in document order (parent before children).
88- * Skips children of elements with data-x-for or data-x-if since those
99- * will be processed when the parent element is cloned and mounted.
66+ * Walk the DOM tree and collect all elements with data-volt-* attributes in document order (parent before children).
77+ *
88+ * Skips children of elements with data-volt-for or data-volt-if since those will be processed when the parent element is cloned and mounted.
109 *
1110 * @param root - The root element to start walking from
1212- * @returns Array of elements with data-x-* attributes
1111+ * @returns Array of elements with data-volt-* attributes
1312 */
1413export function walkDOM(root: Element): Element[] {
1514 const elements: Element[] = [];
···1918 elements.push(element);
20192120 if (
2222- Object.hasOwn((element as HTMLElement).dataset, "xFor")
2323- || Object.hasOwn((element as HTMLElement).dataset, "xIf")
2121+ Object.hasOwn((element as HTMLElement).dataset, "voltFor")
2222+ || Object.hasOwn((element as HTMLElement).dataset, "voltIf")
2423 ) {
2524 return;
2625 }
···3736}
38373938/**
4040- * Check if an element has any data-x-* attributes.
3939+ * Check if an element has any data-volt-* attributes.
4140 *
4241 * @param element - Element to check
4342 * @returns true if element has any Volt attributes
4443 */
4544export function hasVoltAttribute(element: Element): boolean {
4646- return [...element.attributes].some((attribute) => attribute.name.startsWith("data-x-"));
4545+ return [...element.attributes].some((attribute) => attribute.name.startsWith("data-volt-"));
4746}
48474948/**
5050- * Get all data-x-* attributes from an element.
4949+ * Get all data-volt-* attributes from an element.
5050+ * Excludes charge metadata attributes (state, computed:*) that are processed separately.
5151 *
5252 * @param element - Element to get attributes from
5353- * @returns Map of attribute names to values (without the data-x- prefix)
5353+ * @returns Map of attribute names to values (without the data-volt- prefix)
5454 */
5555export function getVoltAttributes(element: Element): Map<string, string> {
5656 const attributes = new Map<string, string>();
57575858 for (const attribute of element.attributes) {
5959- if (attribute.name.startsWith("data-x-")) {
6060- // Remove "data-x-" prefix
6161- attributes.set(attribute.name.slice(7), attribute.value);
5959+ if (attribute.name.startsWith("data-volt-")) {
6060+ const name = attribute.name.slice(10);
6161+6262+ // Skip charge metadata attributes
6363+ if (name === "state" || name.startsWith("computed:")) {
6464+ continue;
6565+ }
6666+6767+ attributes.set(name, attribute.value);
6268 }
6369 }
6470···99105100106/**
101107 * Parse a class binding expression.
102102- * Supports both string values ("active") and object notation ({active: true}).
108108+ * Supports string values ("active"), object notation ({active: true}),
109109+ * and other primitives (true, false, numbers) which are converted to strings.
103110 *
104111 * @param value - The class value or object
105112 * @returns Map of class names to boolean values
···119126 classes.set(key, Boolean(value_));
120127 }
121128 }
129129+ break;
130130+ }
131131+ case "boolean":
132132+ case "number": {
133133+ classes.set(String(value), true);
122134 break;
123135 }
124136 }
+516-55
src/core/evaluator.ts
···11/**
22- * Safe expression evaluation of simple expressions without using eval() for bindings
22+ * Safe expression evaluation with operators support
33+ * Implements a recursive descent parser for expressions without using eval()
34 */
4555-import type { Scope } from "../types/volt";
66+import type { Dep, Scope } from "$types/volt";
6778/**
88- * Evaluate a simple expression against a scope object.
99- * Supports:
1010- * - Property access: "count", "user.name", "items.length"
1111- * - Simple literals: "true", "false", "null", "undefined"
1212- * - Numbers: "42", "3.14"
1313- * - Strings: "'hello'", '"world"'
99+ * Token types for lexical analysis
1010+ */
1111+type TokenType =
1212+ | "NUMBER"
1313+ | "STRING"
1414+ | "TRUE"
1515+ | "FALSE"
1616+ | "NULL"
1717+ | "UNDEFINED"
1818+ | "IDENTIFIER"
1919+ | "DOT"
2020+ | "LBRACKET"
2121+ | "RBRACKET"
2222+ | "LPAREN"
2323+ | "RPAREN"
2424+ | "PLUS"
2525+ | "MINUS"
2626+ | "STAR"
2727+ | "SLASH"
2828+ | "PERCENT"
2929+ | "BANG"
3030+ | "EQ_EQ_EQ"
3131+ | "BANG_EQ_EQ"
3232+ | "LT"
3333+ | "GT"
3434+ | "LT_EQ"
3535+ | "GT_EQ"
3636+ | "AND_AND"
3737+ | "OR_OR"
3838+ | "EOF";
3939+4040+/**
4141+ * Token representing a lexical unit
4242+ */
4343+type Token = { type: TokenType; value: unknown; start: number; end: number };
4444+4545+/**
4646+ * Tokenize an expression string into a stream of tokens
1447 *
1515- * @param expression - The expression string to evaluate
1616- * @param scope - The scope object containing values
1717- * @returns The evaluated result
4848+ * @param expr - The expression string
4949+ * @returns Array of tokens
1850 */
1919-export function evaluate(expression: string, scope: Scope): unknown {
2020- const trimmed = expression.trim();
5151+function tokenize(expr: string): Token[] {
5252+ const tokens: Token[] = [];
5353+ let pos = 0;
21542222- switch (trimmed) {
2323- case "true": {
2424- return true;
5555+ while (pos < expr.length) {
5656+ const char = expr[pos];
5757+5858+ if (/\s/.test(char)) {
5959+ pos++;
6060+ continue;
2561 }
2626- case "false": {
2727- return false;
6262+6363+ if (/\d/.test(char) || (char === "-" && pos + 1 < expr.length && /\d/.test(expr[pos + 1]))) {
6464+ const start = pos;
6565+ if (char === "-") pos++;
6666+ while (pos < expr.length && /[\d.]/.test(expr[pos])) {
6767+ pos++;
6868+ }
6969+ tokens.push({ type: "NUMBER", value: Number(expr.slice(start, pos)), start, end: pos });
7070+ continue;
2871 }
2929- case "null": {
3030- return null;
7272+7373+ if (char === "\"" || char === "'") {
7474+ const start = pos;
7575+ const quote = char;
7676+ pos++;
7777+ let value = "";
7878+ while (pos < expr.length && expr[pos] !== quote) {
7979+ if (expr[pos] === "\\") {
8080+ pos++;
8181+ if (pos < expr.length) {
8282+ value += expr[pos];
8383+ }
8484+ } else {
8585+ value += expr[pos];
8686+ }
8787+ pos++;
8888+ }
8989+ if (pos < expr.length) pos++;
9090+ tokens.push({ type: "STRING", value, start, end: pos });
9191+ continue;
3192 }
3232- case "undefined": {
3333- return undefined;
9393+9494+ if (/[a-zA-Z_$]/.test(char)) {
9595+ const start = pos;
9696+ while (pos < expr.length && /[a-zA-Z0-9_$]/.test(expr[pos])) {
9797+ pos++;
9898+ }
9999+ const value = expr.slice(start, pos);
100100+101101+ switch (value) {
102102+ case "true": {
103103+ tokens.push({ type: "TRUE", value: true, start, end: pos });
104104+ break;
105105+ }
106106+ case "false": {
107107+ tokens.push({ type: "FALSE", value: false, start, end: pos });
108108+ break;
109109+ }
110110+ case "null": {
111111+ tokens.push({ type: "NULL", value: null, start, end: pos });
112112+ break;
113113+ }
114114+ case "undefined": {
115115+ tokens.push({ type: "UNDEFINED", value: undefined, start, end: pos });
116116+ break;
117117+ }
118118+ default: {
119119+ tokens.push({ type: "IDENTIFIER", value, start, end: pos });
120120+ }
121121+ }
122122+ continue;
34123 }
3535- default: {
3636- const numberMatch = /^-?\d+(\.\d+)?$/.exec(trimmed);
3737- if (numberMatch) {
3838- return Number(trimmed);
124124+125125+ const start = pos;
126126+127127+ if (pos + 2 < expr.length) {
128128+ const threeChar = expr.slice(pos, pos + 3);
129129+ if (threeChar === "===") {
130130+ tokens.push({ type: "EQ_EQ_EQ", value: "===", start, end: pos + 3 });
131131+ pos += 3;
132132+ continue;
39133 }
134134+ if (threeChar === "!==") {
135135+ tokens.push({ type: "BANG_EQ_EQ", value: "!==", start, end: pos + 3 });
136136+ pos += 3;
137137+ continue;
138138+ }
139139+ }
401404141- const stringMatch = /^(['"])(.*)\1$/.exec(trimmed);
4242- if (stringMatch) {
4343- return stringMatch[2];
141141+ if (pos + 1 < expr.length) {
142142+ const twoChar = expr.slice(pos, pos + 2);
143143+ switch (twoChar) {
144144+ case "<=": {
145145+ tokens.push({ type: "LT_EQ", value: "<=", start, end: pos + 2 });
146146+ pos += 2;
147147+ continue;
148148+ }
149149+ case ">=": {
150150+ tokens.push({ type: "GT_EQ", value: ">=", start, end: pos + 2 });
151151+ pos += 2;
152152+ continue;
153153+ }
154154+ case "&&": {
155155+ tokens.push({ type: "AND_AND", value: "&&", start, end: pos + 2 });
156156+ pos += 2;
157157+ continue;
158158+ }
159159+ case "||": {
160160+ tokens.push({ type: "OR_OR", value: "||", start, end: pos + 2 });
161161+ pos += 2;
162162+ continue;
163163+ }
44164 }
165165+ }
451664646- return resolvePath(trimmed, scope);
167167+ switch (char) {
168168+ case ".": {
169169+ tokens.push({ type: "DOT", value: ".", start, end: pos + 1 });
170170+ pos++;
171171+ break;
172172+ }
173173+ case "[": {
174174+ tokens.push({ type: "LBRACKET", value: "[", start, end: pos + 1 });
175175+ pos++;
176176+ break;
177177+ }
178178+ case "]": {
179179+ tokens.push({ type: "RBRACKET", value: "]", start, end: pos + 1 });
180180+ pos++;
181181+ break;
182182+ }
183183+ case "(": {
184184+ tokens.push({ type: "LPAREN", value: "(", start, end: pos + 1 });
185185+ pos++;
186186+ break;
187187+ }
188188+ case ")": {
189189+ tokens.push({ type: "RPAREN", value: ")", start, end: pos + 1 });
190190+ pos++;
191191+ break;
192192+ }
193193+ case "+": {
194194+ tokens.push({ type: "PLUS", value: "+", start, end: pos + 1 });
195195+ pos++;
196196+ break;
197197+ }
198198+ case "-": {
199199+ tokens.push({ type: "MINUS", value: "-", start, end: pos + 1 });
200200+ pos++;
201201+ break;
202202+ }
203203+ case "*": {
204204+ tokens.push({ type: "STAR", value: "*", start, end: pos + 1 });
205205+ pos++;
206206+ break;
207207+ }
208208+ case "/": {
209209+ tokens.push({ type: "SLASH", value: "/", start, end: pos + 1 });
210210+ pos++;
211211+ break;
212212+ }
213213+ case "%": {
214214+ tokens.push({ type: "PERCENT", value: "%", start, end: pos + 1 });
215215+ pos++;
216216+ break;
217217+ }
218218+ case "!": {
219219+ tokens.push({ type: "BANG", value: "!", start, end: pos + 1 });
220220+ pos++;
221221+ break;
222222+ }
223223+ case "<": {
224224+ tokens.push({ type: "LT", value: "<", start, end: pos + 1 });
225225+ pos++;
226226+ break;
227227+ }
228228+ case ">": {
229229+ tokens.push({ type: "GT", value: ">", start, end: pos + 1 });
230230+ pos++;
231231+ break;
232232+ }
233233+ default: {
234234+ throw new Error(`Unexpected character '${char}' at position ${pos}`);
235235+ }
47236 }
48237 }
238238+239239+ tokens.push({ type: "EOF", value: null, start: pos, end: pos });
240240+ return tokens;
49241}
5024251243/**
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().
5555- *
5656- * @param path - The property path (e.g., "user.name")
5757- * @param scope - The scope object
5858- * @returns The value at that path, or undefined if not found
244244+ * Recursive descent parser for expression evaluation with operator precedence
59245 */
6060-function resolvePath(path: string, scope: Scope): unknown {
6161- const parts = path.split(".");
6262- let current: unknown = scope;
246246+class Parser {
247247+ private tokens: Token[];
248248+ private current = 0;
249249+ private scope: Scope;
250250+251251+ constructor(tokens: Token[], scope: Scope) {
252252+ this.tokens = tokens;
253253+ this.scope = scope;
254254+ }
255255+256256+ /**
257257+ * Parse the expression and return the result
258258+ */
259259+ parse(): unknown {
260260+ return this.parseExpression();
261261+ }
262262+263263+ private parseExpression(): unknown {
264264+ return this.parseLogicalOr();
265265+ }
266266+267267+ private parseLogicalOr(): unknown {
268268+ let left = this.parseLogicalAnd();
269269+270270+ while (this.match("OR_OR")) {
271271+ const right = this.parseLogicalAnd();
272272+ left = Boolean(left) || Boolean(right);
273273+ }
274274+275275+ return left;
276276+ }
277277+278278+ private parseLogicalAnd(): unknown {
279279+ let left = this.parseEquality();
280280+281281+ while (this.match("AND_AND")) {
282282+ const right = this.parseEquality();
283283+ left = Boolean(left) && Boolean(right);
284284+ }
285285+286286+ return left;
287287+ }
288288+289289+ private parseEquality(): unknown {
290290+ let left = this.parseRelational();
291291+292292+ while (true) {
293293+ if (this.match("EQ_EQ_EQ")) {
294294+ const right = this.parseRelational();
295295+ left = left === right;
296296+ } else if (this.match("BANG_EQ_EQ")) {
297297+ const right = this.parseRelational();
298298+ left = left !== right;
299299+ } else {
300300+ break;
301301+ }
302302+ }
303303+304304+ return left;
305305+ }
306306+307307+ private parseRelational(): unknown {
308308+ let left = this.parseAdditive();
309309+310310+ while (true) {
311311+ if (this.match("LT")) {
312312+ const right = this.parseAdditive();
313313+ left = (left as number) < (right as number);
314314+ } else if (this.match("GT")) {
315315+ const right = this.parseAdditive();
316316+ left = (left as number) > (right as number);
317317+ } else if (this.match("LT_EQ")) {
318318+ const right = this.parseAdditive();
319319+ left = (left as number) <= (right as number);
320320+ } else if (this.match("GT_EQ")) {
321321+ const right = this.parseAdditive();
322322+ left = (left as number) >= (right as number);
323323+ } else {
324324+ break;
325325+ }
326326+ }
327327+328328+ return left;
329329+ }
330330+331331+ private parseAdditive(): unknown {
332332+ let left = this.parseMultiplicative();
333333+334334+ while (true) {
335335+ if (this.match("PLUS")) {
336336+ const right = this.parseMultiplicative();
337337+ left = (left as number) + (right as number);
338338+ } else if (this.match("MINUS")) {
339339+ const right = this.parseMultiplicative();
340340+ left = (left as number) - (right as number);
341341+ } else {
342342+ break;
343343+ }
344344+ }
345345+346346+ return left;
347347+ }
348348+349349+ private parseMultiplicative(): unknown {
350350+ let left = this.parseUnary();
351351+352352+ while (true) {
353353+ if (this.match("STAR")) {
354354+ const right = this.parseUnary();
355355+ left = (left as number) * (right as number);
356356+ } else if (this.match("SLASH")) {
357357+ const right = this.parseUnary();
358358+ left = (left as number) / (right as number);
359359+ } else if (this.match("PERCENT")) {
360360+ const right = this.parseUnary();
361361+ left = (left as number) % (right as number);
362362+ } else {
363363+ break;
364364+ }
365365+ }
366366+367367+ return left;
368368+ }
369369+370370+ private parseUnary(): unknown {
371371+ if (this.match("BANG")) {
372372+ const operand = this.parseUnary();
373373+ return !operand;
374374+ }
375375+376376+ if (this.match("MINUS")) {
377377+ const operand = this.parseUnary();
378378+ return -(operand as number);
379379+ }
380380+381381+ if (this.match("PLUS")) {
382382+ const operand = this.parseUnary();
383383+ return +(operand as number);
384384+ }
385385+386386+ return this.parseMemberAccess();
387387+ }
388388+389389+ private parseMemberAccess(): unknown {
390390+ let object = this.parsePrimary();
391391+392392+ while (true) {
393393+ if (this.match("DOT")) {
394394+ const property = this.consume("IDENTIFIER", "Expected property name after '.'");
395395+ object = this.getMember(object, property.value as string);
396396+ } else if (this.match("LBRACKET")) {
397397+ const index = this.parseExpression();
398398+ this.consume("RBRACKET", "Expected ']' after member access");
399399+ object = this.getMember(object, index);
400400+ } else {
401401+ break;
402402+ }
403403+ }
404404+405405+ return object;
406406+ }
407407+408408+ private parsePrimary(): unknown {
409409+ if (this.match("NUMBER", "STRING", "TRUE", "FALSE", "NULL", "UNDEFINED")) {
410410+ return this.previous().value;
411411+ }
412412+413413+ if (this.match("IDENTIFIER")) {
414414+ const identifier = this.previous().value as string;
415415+ return this.resolvePropPath(identifier);
416416+ }
634176464- for (const part of parts) {
6565- if (current === null || current === undefined) {
418418+ if (this.match("LPAREN")) {
419419+ const expr = this.parseExpression();
420420+ this.consume("RPAREN", "Expected ')' after expression");
421421+ return expr;
422422+ }
423423+424424+ throw new Error(`Unexpected token: ${this.peek().type}`);
425425+ }
426426+427427+ private getMember(object: unknown, key: unknown): unknown {
428428+ if (object === null || object === undefined) {
66429 return undefined;
67430 }
684316969- if (typeof current === "object" && part in (current as Record<string, unknown>)) {
7070- current = (current as Record<string, unknown>)[part];
7171- } else {
432432+ // Access property - works on objects, strings, arrays, etc.
433433+ const value = (object as Record<string | number, unknown>)[key as string | number];
434434+435435+ if (isSignal(value)) {
436436+ return value.get();
437437+ }
438438+439439+ return value;
440440+ }
441441+442442+ private resolvePropPath(path: string): unknown {
443443+ if (!(path in this.scope)) {
72444 return undefined;
73445 }
446446+447447+ const value = this.scope[path];
448448+449449+ if (isSignal(value)) {
450450+ return value.get();
451451+ }
452452+453453+ return value;
74454 }
754557676- if (isSignal(current)) {
7777- return current.get();
456456+ private match(...types: TokenType[]): boolean {
457457+ for (const type of types) {
458458+ if (this.check(type)) {
459459+ this.advance();
460460+ return true;
461461+ }
462462+ }
463463+ return false;
464464+ }
465465+466466+ private check(type: TokenType): boolean {
467467+ if (this.isAtEnd()) return false;
468468+ return this.peek().type === type;
469469+ }
470470+471471+ private advance(): Token {
472472+ if (!this.isAtEnd()) this.current++;
473473+ return this.previous();
474474+ }
475475+476476+ private isAtEnd(): boolean {
477477+ return this.peek().type === "EOF";
78478 }
794798080- return current;
480480+ private peek(): Token {
481481+ return this.tokens[this.current];
482482+ }
483483+484484+ private previous(): Token {
485485+ return this.tokens[this.current - 1];
486486+ }
487487+488488+ private consume(type: TokenType, message: string): Token {
489489+ if (this.check(type)) return this.advance();
490490+ throw new Error(`${message} at position ${this.peek().start}`);
491491+ }
81492}
824938383-/**
8484- * Check if a value is a Signal or ComputedSignal.
8585- *
8686- * @param value - Value to check
8787- * @returns true if the value is a Signal or ComputedSignal
8888- */
8989-function isSignal(value: unknown): value is { get: () => unknown } {
494494+export function isSignal(value: unknown): value is Dep {
90495 return (typeof value === "object"
91496 && value !== null
92497 && "get" in value
···94499 && typeof value.get === "function"
95500 && typeof (value as { subscribe: unknown }).subscribe === "function");
96501}
502502+503503+/**
504504+ * Evaluate an expression against a scope object.
505505+ *
506506+ * Supports literals, property access, operators, and member access.
507507+ *
508508+ * @param expression - The expression string to evaluate
509509+ * @param scope - The scope object containing values
510510+ * @returns The evaluated result
511511+ */
512512+export function evaluate(expression: string, scope: Scope): unknown {
513513+ try {
514514+ const tokens = tokenize(expression);
515515+ const parser = new Parser(tokens, scope);
516516+ return parser.parse();
517517+ } catch (error) {
518518+ console.error(`Error evaluating expression "${expression}":`, error);
519519+ return undefined;
520520+ }
521521+}
522522+523523+/**
524524+ * Extract all signal dependencies from an expression by finding identifiers
525525+ * that correspond to signals in the scope.
526526+ *
527527+ * @param expression - The expression to analyze
528528+ * @param scope - The scope containing potential signal dependencies
529529+ * @returns Array of signals found in the expression
530530+ */
531531+export function extractDependencies(expression: string, scope: Scope): Array<Dep> {
532532+ const dependencies: Array<Dep> = [];
533533+ const identifierRegex = /\b([a-zA-Z_$][\w$]*)\b/g;
534534+ const matches = expression.matchAll(identifierRegex);
535535+ const seen = new Set<string>();
536536+537537+ for (const match of matches) {
538538+ const identifier = match[1];
539539+540540+ if (["true", "false", "null", "undefined"].includes(identifier)) {
541541+ continue;
542542+ }
543543+544544+ if (seen.has(identifier)) {
545545+ continue;
546546+ }
547547+548548+ seen.add(identifier);
549549+550550+ const value = scope[identifier];
551551+ if (isSignal(value)) {
552552+ dependencies.push(value);
553553+ }
554554+ }
555555+556556+ return dependencies;
557557+}
+2-1
src/index.ts
···44 * @packageDocumentation
55 */
6677-export type { ComputedSignal, PluginContext, PluginHandler, Signal } from "$types/volt";
77+export type { ChargedRoot, ChargeResult, ComputedSignal, PluginContext, PluginHandler, Signal } from "$types/volt";
88export { mount } from "@volt/core/binder";
99+export { charge } from "@volt/core/charge";
910export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "@volt/core/plugin";
1011export { computed, effect, signal } from "@volt/core/signal";
···99 * Scroll plugin handler.
1010 * Manages various scroll-related behaviors.
1111 *
1212- * Syntax: data-x-scroll="mode:signalPath"
1212+ * Syntax: data-volt-scroll="mode:signalPath"
1313 * Modes:
1414 * - restore:signalPath - Save/restore scroll position
1515 * - scrollTo:signalPath - Scroll to element when signal changes
···4949}
50505151/**
5252- * Save and restore scroll position.
5353- * Saves current scroll position to signal on scroll events.
5454- * Restores scroll position from signal on mount.
5252+ * Saves current scroll position to signal on scroll events; Restores scroll position from signal on mount.
5553 */
5654function handleScrollRestore(context: PluginContext, signalPath: string): void {
5755 const signal = context.findSignal(signalPath);
···79778078/**
8179 * 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.
8080+ *
8181+ * Listens for changes to the target signal to determine position
8382 */
8483function handleScrollTo(context: PluginContext, signalPath: string): void {
8584 const signal = context.findSignal(signalPath);
···105104106105/**
107106 * Update signal when element enters or exits viewport.
107107+ *
108108 * Uses Intersection Observer to track visibility.
109109 */
110110function handleScrollSpy(context: PluginContext, signalPath: string): void {
···132132}
133133134134/**
135135- * Enable smooth scrolling behavior.
136136- * Applies smooth scroll behavior based on signal value.
135135+ * Enable smooth scrolling behavior and apply based on signal value.
137136 */
138137function handleSmoothScroll(context: PluginContext, signalPath: string): void {
139138 const signal = context.findSignal(signalPath);
+1-1
src/plugins/url.ts
···99 * URL plugin handler.
1010 * Synchronizes signal values with URL parameters and hash.
1111 *
1212- * Syntax: data-x-url="mode:signalPath"
1212+ * Syntax: data-volt-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
+20-5
src/types/volt.d.ts
···55/**
66 * Context object available to all bindings
77 */
88-export interface BindingContext {
99- element: Element;
1010- scope: Scope;
1111- cleanups: CleanupFunction[];
1212-}
88+export type BindingContext = { element: Element; scope: Scope; cleanups: CleanupFunction[] };
1391410/**
1511 * Context object provided to plugin handlers.
···10298 set(key: string, value: unknown): Promise<void> | void;
10399 remove(key: string): Promise<void> | void;
104100}
101101+102102+/**
103103+ * Information about a mounted Volt root after charging
104104+ *
105105+ * element: The root element that was mounted
106106+ * scope: The reactive scope created for this root
107107+ * cleanup: Cleanup function to unmount this root
108108+ */
109109+export type ChargedRoot = { element: Element; scope: Scope; cleanup: CleanupFunction };
110110+111111+/**
112112+ * Result of charging Volt roots
113113+ *
114114+ * roots: Array of all charged roots
115115+ * cleanup: Cleanup function to unmount all roots
116116+ */
117117+export type ChargeResult = { roots: ChargedRoot[]; cleanup: CleanupFunction };
118118+119119+export type Dep = { get: () => unknown; subscribe: (callback: (value: unknown) => void) => () => void };