···88import { getPlugin } from "./plugin";
991010/**
1111- * Mount Volt.js on a root element and its descendants.
1212- * Binds all data-x-* attributes to the provided scope.
1111+ * Mount Volt.js on a root element and its descendants and binds all data-x-* attributes to the provided scope.
1312 * Returns a cleanup function to unmount and dispose all bindings.
1413 *
1514 * @param root - Root element to mount on
···2423 const attributes = getVoltAttributes(element);
2524 const context: BindingContext = { element, scope, cleanups: [] };
26252727- for (const [name, value] of attributes) {
2828- bindAttribute(context, name, value);
2626+ if (attributes.has("for")) {
2727+ const forExpression = attributes.get("for")!;
2828+ bindFor(context, forExpression);
2929+ } else if (attributes.has("if")) {
3030+ const ifExpression = attributes.get("if")!;
3131+ bindIf(context, ifExpression);
3232+ } else {
3333+ for (const [name, value] of attributes) {
3434+ bindAttribute(context, name, value);
3535+ }
2936 }
30373138 allCleanups.push(...context.cleanups);
···6875 }
6976 case "class": {
7077 bindClass(context, value);
7878+ break;
7979+ }
8080+ case "for": {
8181+ bindFor(context, value);
7182 break;
7283 }
7384 default: {
···222233 }
223234224235 if (
225225- typeof current === "object" && current !== null && "get" in current && "set" in current && "subscribe" in current
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"
226242 ) {
227243 return current as Signal<unknown>;
244244+ }
245245+246246+ return undefined;
247247+}
248248+249249+/**
250250+ * Bind data-x-for to render a list of items.
251251+ * Subscribes to array signal and re-renders when array changes.
252252+ *
253253+ * @param context - Binding context
254254+ * @param expression - Expression like "item in items" or "(item, index) in items"
255255+ */
256256+function bindFor(context: BindingContext, expression: string): void {
257257+ const parsed = parseForExpression(expression);
258258+ if (!parsed) {
259259+ console.error(`Invalid data-x-for expression: "${expression}"`);
260260+ return;
261261+ }
262262+263263+ const { itemName, indexName, arrayPath } = parsed;
264264+ const template = context.element as HTMLElement;
265265+ const parent = template.parentElement;
266266+267267+ if (!parent) {
268268+ console.error("data-x-for element must have a parent");
269269+ return;
270270+ }
271271+272272+ const placeholder = document.createComment(`for: ${expression}`);
273273+ template.before(placeholder);
274274+ template.remove();
275275+276276+ const renderedElements: Element[] = [];
277277+ const renderedCleanups: CleanupFunction[] = [];
278278+279279+ const render = () => {
280280+ for (const cleanup of renderedCleanups) {
281281+ cleanup();
282282+ }
283283+ renderedCleanups.length = 0;
284284+285285+ for (const element of renderedElements) {
286286+ element.remove();
287287+ }
288288+ renderedElements.length = 0;
289289+290290+ const arrayValue = evaluate(arrayPath, context.scope);
291291+ if (!Array.isArray(arrayValue)) {
292292+ return;
293293+ }
294294+295295+ for (const [index, item] of arrayValue.entries()) {
296296+ const clone = template.cloneNode(true) as Element;
297297+ delete (clone as HTMLElement).dataset.xFor;
298298+299299+ const itemScope: Scope = { ...context.scope, [itemName]: item };
300300+ if (indexName) {
301301+ itemScope[indexName] = index;
302302+ }
303303+304304+ const cleanup = mount(clone, itemScope);
305305+ renderedCleanups.push(cleanup);
306306+ renderedElements.push(clone);
307307+308308+ placeholder.before(clone);
309309+ }
310310+ };
311311+312312+ render();
313313+314314+ const signal = findSignalInScope(context.scope, arrayPath);
315315+ if (signal) {
316316+ const unsubscribe = signal.subscribe(render);
317317+ context.cleanups.push(unsubscribe);
318318+ }
319319+320320+ context.cleanups.push(() => {
321321+ for (const cleanup of renderedCleanups) {
322322+ cleanup();
323323+ }
324324+ });
325325+}
326326+327327+/**
328328+ * Bind data-x-if to conditionally render an element.
329329+ * Subscribes to condition signal and shows/hides element when condition changes.
330330+ *
331331+ * @param context - Binding context
332332+ * @param expression - Expression to evaluate as condition
333333+ */
334334+function bindIf(context: BindingContext, expression: string): void {
335335+ const template = context.element as HTMLElement;
336336+ const parent = template.parentElement;
337337+338338+ if (!parent) {
339339+ console.error("data-x-if element must have a parent");
340340+ return;
341341+ }
342342+343343+ const placeholder = document.createComment(`if: ${expression}`);
344344+ template.before(placeholder);
345345+ template.remove();
346346+347347+ let currentElement: Element | undefined;
348348+ let currentCleanup: CleanupFunction | undefined;
349349+350350+ const render = () => {
351351+ const condition = evaluate(expression, context.scope);
352352+ const shouldShow = Boolean(condition);
353353+354354+ if (shouldShow && !currentElement) {
355355+ currentElement = template.cloneNode(true) as Element;
356356+ delete (currentElement as HTMLElement).dataset.xIf;
357357+ currentCleanup = mount(currentElement, context.scope);
358358+ placeholder.before(currentElement);
359359+ } else if (!shouldShow && currentElement) {
360360+ if (currentCleanup) {
361361+ currentCleanup();
362362+ }
363363+ currentElement.remove();
364364+ currentElement = undefined;
365365+ currentCleanup = undefined;
366366+ }
367367+ };
368368+369369+ render();
370370+371371+ const signal = findSignalInScope(context.scope, expression);
372372+ if (signal) {
373373+ const unsubscribe = signal.subscribe(render);
374374+ context.cleanups.push(unsubscribe);
375375+ }
376376+377377+ context.cleanups.push(() => {
378378+ if (currentCleanup) {
379379+ currentCleanup();
380380+ }
381381+ });
382382+}
383383+384384+/**
385385+ * Parse a data-x-for expression.
386386+ * Supports: "item in items" or "(item, index) in items"
387387+ *
388388+ * @param expr - The for expression
389389+ * @returns Parsed parts or undefined if invalid
390390+ */
391391+function parseForExpression(expr: string): { itemName: string; indexName?: string; arrayPath: string } | undefined {
392392+ const trimmed = expr.trim();
393393+394394+ const withIndex = /^\((\w+)\s*,\s*(\w+)\)\s+in\s+(.+)$/.exec(trimmed);
395395+ if (withIndex) {
396396+ return { itemName: withIndex[1], indexName: withIndex[2], arrayPath: withIndex[3].trim() };
397397+ }
398398+399399+ const simple = /^(\w+)\s+in\s+(.+)$/.exec(trimmed);
400400+ if (simple) {
401401+ return { itemName: simple[1], indexName: undefined, arrayPath: simple[2].trim() };
228402 }
229403230404 return undefined;
+19-6
src/core/dom.ts
···55/**
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.
810 *
911 * @param root - The root element to start walking from
1012 * @returns Array of elements with data-x-* attributes
1113 */
1214export function walkDOM(root: Element): Element[] {
1315 const elements: Element[] = [];
1414- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
1616+1717+ function walk(element: Element): void {
1818+ if (hasVoltAttribute(element)) {
1919+ elements.push(element);
2020+2121+ if (
2222+ Object.hasOwn((element as HTMLElement).dataset, "xFor")
2323+ || Object.hasOwn((element as HTMLElement).dataset, "xIf")
2424+ ) {
2525+ return;
2626+ }
2727+ }
15281616- let node = walker.currentNode as Element;
1717- do {
1818- if (hasVoltAttribute(node)) {
1919- elements.push(node);
2929+ for (const child of element.children) {
3030+ walk(child);
2031 }
2121- } while ((node = walker.nextNode() as Element));
3232+ }
3333+3434+ walk(root);
22352336 return elements;
2437}
+8-8
src/core/evaluator.ts
···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"'
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
···8181}
82828383/**
8484- * Check if a value is a Signal.
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
8787+ * @returns true if the value is a Signal or ComputedSignal
8888 */
8989function isSignal(value: unknown): value is { get: () => unknown } {
9090 return (typeof value === "object"
9191 && value !== null
9292 && "get" in value
9393- && "set" in value
9493 && "subscribe" in value
9595- && typeof value.get === "function");
9494+ && typeof value.get === "function"
9595+ && typeof (value as { subscribe: unknown }).subscribe === "function");
9696}