···11+/**
22+ * DOM utility functions
33+ */
44+55+/**
66+ * Walk the DOM tree and collect all elements with data-x-* attributes.
77+ * Returns elements in document order (parent before children).
88+ *
99+ * @param root - The root element to start walking from
1010+ * @returns Array of elements with data-x-* attributes
1111+ */
1212+export function walkDOM(root: Element): Element[] {
1313+ const elements: Element[] = [];
1414+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
1515+1616+ let node = walker.currentNode as Element;
1717+ do {
1818+ if (hasVoltAttribute(node)) {
1919+ elements.push(node);
2020+ }
2121+ } while ((node = walker.nextNode() as Element));
2222+2323+ return elements;
2424+}
2525+2626+/**
2727+ * Check if an element has any data-x-* attributes.
2828+ *
2929+ * @param element - Element to check
3030+ * @returns true if element has any Volt attributes
3131+ */
3232+export function hasVoltAttribute(element: Element): boolean {
3333+ return [...element.attributes].some((attribute) => attribute.name.startsWith("data-x-"));
3434+}
3535+3636+/**
3737+ * Get all data-x-* attributes from an element.
3838+ *
3939+ * @param element - Element to get attributes from
4040+ * @returns Map of attribute names to values (without the data-x- prefix)
4141+ */
4242+export function getVoltAttributes(element: Element): Map<string, string> {
4343+ const attributes = new Map<string, string>();
4444+4545+ for (const attribute of element.attributes) {
4646+ if (attribute.name.startsWith("data-x-")) {
4747+ // Remove "data-x-" prefix
4848+ attributes.set(attribute.name.slice(7), attribute.value);
4949+ }
5050+ }
5151+5252+ return attributes;
5353+}
5454+5555+/**
5656+ * Set the text content of an element safely.
5757+ *
5858+ * @param element - Element to update
5959+ * @param value - Text value to set
6060+ */
6161+export function setText(element: Element, value: unknown): void {
6262+ element.textContent = String(value ?? "");
6363+}
6464+6565+/**
6666+ * Set the HTML content of an element safely.
6767+ * Note: This trusts the input HTML and should only be used with sanitized content.
6868+ *
6969+ * @param element - Element to update
7070+ * @param value - HTML string to set
7171+ */
7272+export function setHTML(element: Element, value: string): void {
7373+ element.innerHTML = value;
7474+}
7575+7676+/**
7777+ * Add or remove a CSS class from an element.
7878+ *
7979+ * @param element - Element to update
8080+ * @param className - Class name to toggle
8181+ * @param add - Whether to add (true) or remove (false) the class
8282+ */
8383+export function toggleClass(element: Element, className: string, add: boolean): void {
8484+ element.classList.toggle(className, add);
8585+}
8686+8787+/**
8888+ * Parse a class binding expression.
8989+ * Supports both string values ("active") and object notation ({active: true}).
9090+ *
9191+ * @param value - The class value or object
9292+ * @returns Map of class names to boolean values
9393+ */
9494+export function parseClassBinding(value: unknown): Map<string, boolean> {
9595+ const classes = new Map<string, boolean>();
9696+ switch (typeof value) {
9797+ case "string": {
9898+ for (const className of value.split(/\s+/).filter(Boolean)) {
9999+ classes.set(className, true);
100100+ }
101101+ break;
102102+ }
103103+ case "object": {
104104+ if (value !== null) {
105105+ for (const [key, value_] of Object.entries(value)) {
106106+ classes.set(key, Boolean(value_));
107107+ }
108108+ }
109109+ break;
110110+ }
111111+ }
112112+113113+ return classes;
114114+}
+76
src/core/evaluator.ts
···11+/**
22+ * Safe expression evaluation of simple expressions without using eval() for bindings
33+ */
44+55+export type Scope = Record<string, unknown>;
66+77+/**
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"'
1414+ *
1515+ * @param expression - The expression string to evaluate
1616+ * @param scope - The scope object containing values
1717+ * @returns The evaluated result
1818+ */
1919+export function evaluate(expression: string, scope: Scope): unknown {
2020+ const trimmed = expression.trim();
2121+2222+ switch (trimmed) {
2323+ case "true": {
2424+ return true;
2525+ }
2626+ case "false": {
2727+ return false;
2828+ }
2929+ case "null": {
3030+ return null;
3131+ }
3232+ case "undefined": {
3333+ return undefined;
3434+ }
3535+ default: {
3636+ const numberMatch = /^-?\d+(\.\d+)?$/.exec(trimmed);
3737+ if (numberMatch) {
3838+ return Number(trimmed);
3939+ }
4040+4141+ const stringMatch = /^(['"])(.*)\1$/.exec(trimmed);
4242+ if (stringMatch) {
4343+ return stringMatch[2];
4444+ }
4545+4646+ return resolvePath(trimmed, scope);
4747+ }
4848+ }
4949+}
5050+5151+/**
5252+ * Resolve a property path in a scope object.
5353+ * Supports nested property access like "user.profile.name".
5454+ *
5555+ * @param path - The property path (e.g., "user.name")
5656+ * @param scope - The scope object
5757+ * @returns The value at that path, or undefined if not found
5858+ */
5959+function resolvePath(path: string, scope: Scope): unknown {
6060+ const parts = path.split(".");
6161+ let current: unknown = scope;
6262+6363+ for (const part of parts) {
6464+ if (current === null || current === undefined) {
6565+ return undefined;
6666+ }
6767+6868+ if (typeof current === "object" && part in (current as Record<string, unknown>)) {
6969+ current = (current as Record<string, unknown>)[part];
7070+ } else {
7171+ return undefined;
7272+ }
7373+ }
7474+7575+ return current;
7676+}
+74
src/core/signal.ts
···11+/**
22+ * A reactive primitive that notifies subscribers when its value changes.
33+ * Updates are batched in microtasks to avoid redundant notifications.
44+ */
55+export interface Signal<T> {
66+ /**
77+ * Get the current value of the signal.
88+ */
99+ get(): T;
1010+1111+ /**
1212+ * Update the signal's value.
1313+ * If the new value differs from the current value, subscribers will be notified
1414+ * asynchronously in a batched microtask.
1515+ */
1616+ set(value: T): void;
1717+1818+ /**
1919+ * Subscribe to changes in the signal's value.
2020+ * The callback is invoked with the new value whenever it changes.
2121+ * Returns an unsubscribe function to remove the subscription.
2222+ */
2323+ subscribe(callback: (value: T) => void): () => void;
2424+}
2525+2626+/**
2727+ * Creates a new signal with the given initial value.
2828+ * Signals are reactive primitives that automatically notify subscribers when changed.
2929+ *
3030+ * @param initialValue - The initial value of the signal
3131+ * @returns A Signal object with get, set, and subscribe methods
3232+ *
3333+ * @example
3434+ * const count = signal(0);
3535+ * count.subscribe(value => console.log('Count:', value));
3636+ * count.set(1); // Logs: Count: 1
3737+ */
3838+export function signal<T>(initialValue: T): Signal<T> {
3939+ let value = initialValue;
4040+ const subscribers = new Set<(value: T) => void>();
4141+4242+ const notify = () => {
4343+ for (const callback of subscribers) {
4444+ try {
4545+ callback(value);
4646+ } catch (error) {
4747+ console.error("Error in signal subscriber:", error);
4848+ }
4949+ }
5050+ };
5151+5252+ return {
5353+ get() {
5454+ return value;
5555+ },
5656+5757+ set(newValue: T) {
5858+ if (value === newValue) {
5959+ return;
6060+ }
6161+6262+ value = newValue;
6363+ notify();
6464+ },
6565+6666+ subscribe(callback: (value: T) => void) {
6767+ subscribers.add(callback);
6868+6969+ return () => {
7070+ subscribers.delete(callback);
7171+ };
7272+ },
7373+ };
7474+}