a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1/**
2 * Binder system for mounting and managing VoltX bindings
3 */
4
5import { executeSurgeEnter, executeSurgeLeave, hasSurge } from "$plugins/surge";
6import type { Nullable, Optional } from "$types/helpers";
7import type {
8 BindingContext,
9 CleanupFunction,
10 FormControlElement,
11 Modifier,
12 PluginContext,
13 PluginHandler,
14 Scope,
15 Signal,
16} from "$types/volt";
17import { BOOLEAN_ATTRS } from "./constants";
18import { getVoltAttrs, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom";
19import { report } from "./error";
20import { evaluate } from "./evaluator";
21import { execGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle";
22import { debounce, getModifierValue, hasModifier, parseModifiers, throttle } from "./modifiers";
23import { getPlugin } from "./plugin";
24import { createScopeMetadata, getPin, registerPin } from "./scope-metadata";
25import { createArc, createProbe, createPulse, createUid } from "./scope-vars";
26import { findScopedSignal, isNil, updateAndRegister } from "./shared";
27import { getStore } from "./store";
28
29/**
30 * Directive registry for custom bindings
31 *
32 * Allows modules (like HTTP) to register directive handlers that can be tree-shaken when not imported.
33 */
34type DirectiveHandler = (ctx: BindingContext, value: string, modifiers?: Modifier[]) => void;
35
36const directiveRegistry = new Map<string, DirectiveHandler>();
37
38/**
39 * Register a custom directive handler
40 *
41 * Used by optional modules (HTTP, plugins) to register directive handlers that can be tree-shaken when the module is not imported.
42 *
43 * @param name - Directive name (without data-volt- prefix)
44 * @param handler - Handler function that processes the directive
45 */
46export function registerDirective(name: string, handler: DirectiveHandler): void {
47 directiveRegistry.set(name, handler);
48}
49
50function scheduleTransitionTask(cb: () => void): void {
51 let executed = false;
52 const wrapped = () => {
53 if (executed) {
54 return;
55 }
56 executed = true;
57 cb();
58 };
59
60 if (typeof requestAnimationFrame === "function") {
61 requestAnimationFrame(wrapped);
62 }
63
64 setTimeout(wrapped, 16);
65}
66
67/**
68 * Mount VoltX on a root element and its descendants and binds all data-volt-* attributes to the provided scope.
69 *
70 * @param root - Root element to mount on
71 * @param scope - Scope object containing signals and data
72 * @returns Cleanup function to unmount and dispose all bindings.
73 */
74export function mount(root: Element, scope: Scope): CleanupFunction {
75 injectSpecialVars(scope, root);
76 execGlobalHooks("beforeMount", root, scope);
77
78 const allElements = walkDOM(root);
79
80 const elements = allElements.filter((element) => {
81 let current: Nullable<Element> = element;
82 while (current) {
83 if (Object.hasOwn((current as HTMLElement).dataset, "voltSkip")) {
84 return false;
85 }
86 if (current === root) break;
87 current = current.parentElement;
88 }
89 return true;
90 });
91
92 const allCleanups: CleanupFunction[] = [];
93 const mountedElements: Element[] = [];
94
95 for (const el of elements) {
96 if (Object.hasOwn((el as HTMLElement).dataset, "voltCloak")) {
97 delete (el as HTMLElement).dataset.voltCloak;
98 }
99
100 const attributes = getVoltAttrs(el);
101 const context: BindingContext = { element: el, scope, cleanups: [] };
102
103 if (attributes.has("for")) {
104 const forExpression = attributes.get("for")!;
105 bindFor(context, forExpression);
106 notifyBindingCreated(el, "for");
107 } else if (attributes.has("if")) {
108 const ifExpression = attributes.get("if")!;
109 bindIf(context, ifExpression);
110 notifyBindingCreated(el, "if");
111 } else {
112 for (const [name, value] of attributes) {
113 bindAttribute(context, name, value);
114 notifyBindingCreated(el, name);
115 }
116 }
117
118 notifyElementMounted(el);
119 mountedElements.push(el);
120 allCleanups.push(...context.cleanups);
121 }
122
123 execGlobalHooks("afterMount", root, scope);
124
125 return () => {
126 execGlobalHooks("beforeUnmount", root);
127
128 for (const element of mountedElements) {
129 notifyElementUnmounted(element);
130 }
131
132 for (const cleanup of allCleanups) {
133 try {
134 cleanup();
135 } catch (error) {
136 report(error as Error, { source: "binding", element: root as HTMLElement });
137 }
138 }
139
140 execGlobalHooks("afterUnmount", root);
141 };
142}
143
144function execPlugin(plugin: PluginHandler, ctx: BindingContext, val: string, base: string) {
145 const pluginCtx = createPluginCtx(ctx);
146 try {
147 plugin(pluginCtx, val);
148 } catch (error) {
149 report(error as Error, {
150 source: "plugin",
151 element: ctx.element as HTMLElement,
152 directive: `data-volt-${base}`,
153 pluginName: base,
154 });
155 }
156}
157
158/**
159 * Bind a single data-volt-* attribute to an element.
160 * Routes to the appropriate binding handler.
161 */
162function bindAttribute(ctx: BindingContext, name: string, value: string): void {
163 if (name.startsWith("on-")) {
164 const eventSpec = name.slice(3);
165 const { baseName: eventName, modifiers } = parseModifiers(eventSpec);
166 bindEvent(ctx, eventName, value, modifiers);
167 return;
168 }
169
170 if (name.startsWith("bind:") || name.startsWith("bind-")) {
171 const attrSpec = name.slice(5);
172 const { baseName: attrName, modifiers } = parseModifiers(attrSpec);
173 bindAttr(ctx, attrName, value, modifiers);
174 return;
175 }
176
177 if (name.includes(":")) {
178 const colonIndex = name.indexOf(":");
179 const pluginName = name.slice(0, colonIndex);
180 const suffix = name.slice(colonIndex + 1);
181 const plugin = getPlugin(pluginName);
182
183 if (plugin) {
184 const combinedVal = `${suffix}:${value}`;
185 execPlugin(plugin, ctx, combinedVal, pluginName);
186 return;
187 }
188 }
189
190 const { baseName, modifiers } = parseModifiers(name);
191
192 switch (baseName) {
193 case "text": {
194 const bindText = bindNode("text");
195 bindText(ctx, value);
196 break;
197 }
198 case "html": {
199 const bindHTML = bindNode("html");
200 bindHTML(ctx, value);
201 break;
202 }
203 case "class": {
204 bindClass(ctx, value);
205 break;
206 }
207 case "show": {
208 bindShow(ctx, value);
209 break;
210 }
211 case "style": {
212 bindStyle(ctx, value);
213 break;
214 }
215 case "model": {
216 bindModel(ctx, value, modifiers);
217 break;
218 }
219 case "pin": {
220 bindPin(ctx, value);
221 break;
222 }
223 case "init": {
224 bindInit(ctx, value);
225 break;
226 }
227 case "for": {
228 bindFor(ctx, value);
229 break;
230 }
231 // data-volt-else is a marker attribute handled by bindIf when processing data-volt-if
232 case "else": {
233 break;
234 }
235 default: {
236 const directiveHandler = directiveRegistry.get(baseName);
237 if (directiveHandler) {
238 directiveHandler(ctx, value, modifiers);
239 return;
240 }
241
242 const plugin = getPlugin(baseName);
243 if (plugin) {
244 execPlugin(plugin, ctx, value, baseName);
245 } else {
246 console.warn(`Unknown binding: data-volt-${baseName}`);
247 }
248 }
249 }
250}
251
252/**
253 * Creates a reactive binding for data-volt-text or data-volt-html that updates element content.
254 * Returns a curried function that handles binding data-volt-text|html to update an element's text or html content
255 * Subscribes to signals in the expression and updates on change.
256 */
257function bindNode(kind: "text" | "html") {
258 return function(ctx: BindingContext, expr: string): void {
259 const update = () => {
260 const value = evaluate(expr, ctx.scope);
261 if (kind === "text") {
262 setText(ctx.element, value);
263 } else {
264 setHTML(ctx.element, String(value ?? ""));
265 }
266 };
267 updateAndRegister(ctx, update, expr);
268 };
269}
270
271/**
272 * Bind data-volt-class to toggle CSS classes. Supports both string and object notation.
273 * Subscribes to signals in the expression and updates on change.
274 */
275function bindClass(ctx: BindingContext, expr: string): void {
276 let prevClasses = new Map<string, boolean>();
277
278 const update = () => {
279 const value = evaluate(expr, ctx.scope);
280 const classes = parseClassBinding(value);
281
282 for (const [className] of prevClasses) {
283 if (!classes.has(className)) {
284 toggleClass(ctx.element, className, false);
285 }
286 }
287
288 for (const [className, shouldAdd] of classes) {
289 toggleClass(ctx.element, className, shouldAdd);
290 }
291
292 prevClasses = classes;
293 };
294
295 updateAndRegister(ctx, update, expr);
296}
297
298/**
299 * Bind data-volt-show to toggle element visibility via CSS display property.
300 * Unlike data-volt-if, this keeps the element in the DOM and toggles display: none.
301 * Integrates with surge plugin for smooth transitions when available.
302 */
303function bindShow(ctx: BindingContext, expr: string): void {
304 const el = ctx.element as HTMLElement;
305 const originalInlineDisplay = el.style.display;
306 const hasSurgeTransition = hasSurge(el);
307
308 if (!hasSurgeTransition) {
309 const update = () => {
310 const value = evaluate(expr, ctx.scope);
311 const shouldShow = Boolean(value);
312
313 if (shouldShow) {
314 el.style.display = originalInlineDisplay;
315 } else {
316 el.style.display = "none";
317 }
318 };
319
320 updateAndRegister(ctx, update, expr);
321 return;
322 }
323
324 let isVisible = el.style.display !== "none";
325 let isTransitioning = false;
326
327 const update = () => {
328 const value = evaluate(expr, ctx.scope);
329 const shouldShow = Boolean(value);
330
331 if (shouldShow === isVisible || isTransitioning) {
332 return;
333 }
334
335 isTransitioning = true;
336
337 scheduleTransitionTask(() => {
338 void (async () => {
339 try {
340 if (shouldShow) {
341 el.style.display = originalInlineDisplay;
342 await executeSurgeEnter(el);
343 isVisible = true;
344 } else {
345 await executeSurgeLeave(el);
346 el.style.display = "none";
347 isVisible = false;
348 }
349 } finally {
350 isTransitioning = false;
351 }
352 })();
353 });
354 };
355
356 updateAndRegister(ctx, update, expr);
357}
358
359/**
360 * Bind data-volt-style to reactively apply inline styles.
361 * Supports
362 * - object notation {color: 'red', fontSize: '16px'}
363 * - string notation 'color: red; font-size: 16px'.
364 */
365function bindStyle(ctx: BindingContext, expr: string): void {
366 const element = ctx.element as HTMLElement;
367
368 const update = () => {
369 const value = evaluate(expr, ctx.scope);
370
371 if (typeof value === "object" && value !== null) {
372 for (const [key, val] of Object.entries(value)) {
373 const cssKey = key.replaceAll(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
374
375 if (isNil(val)) {
376 element.style.removeProperty(cssKey);
377 } else {
378 try {
379 element.style.setProperty(cssKey, String(val));
380 } catch (error) {
381 report(error as Error, {
382 source: "binding",
383 element: element,
384 directive: "data-volt-style",
385 expression: expr,
386 });
387 }
388 }
389 }
390 } else if (typeof value === "string") {
391 element.style.cssText = value;
392 }
393 };
394
395 updateAndRegister(ctx, update, expr);
396}
397
398function extractStatements(expr: string) {
399 const statements: string[] = [];
400 let current = "";
401 let depth = 0;
402 let inString: string | null = null;
403
404 for (const [i, char] of [...expr].entries()) {
405 const prev = i > 0 ? expr[i - 1] : "";
406
407 if ((char === "\"" || char === "'") && prev !== "\\") {
408 if (inString === char) {
409 inString = null;
410 } else if (inString === null) {
411 inString = char;
412 }
413 }
414
415 if (inString === null) {
416 if (char === "(" || char === "{" || char === "[") {
417 depth++;
418 } else if (char === ")" || char === "}" || char === "]") {
419 depth--;
420 }
421 }
422
423 if (char === ";" && depth === 0 && inString === null) {
424 if (current.trim()) {
425 statements.push(current.trim());
426 }
427 current = "";
428 } else {
429 current += char;
430 }
431 }
432
433 if (current.trim()) {
434 statements.push(current.trim());
435 }
436
437 return statements;
438}
439
440/**
441 * Bind data-volt-on-* to attach event listeners with support for modifiers.
442 * Provides $el and $event in the scope for the event handler.
443 *
444 * Supported modifiers:
445 * - .prevent - calls preventDefault()
446 * - .stop - calls stopPropagation()
447 * - .self - only trigger if event.target === element
448 * - .window - attach listener to window
449 * - .document - attach listener to document
450 * - .once - run handler only once
451 * - .debounce[.ms] - debounce handler (default 300ms)
452 * - .throttle[.ms] - throttle handler (default 300ms)
453 * - .passive - add passive event listener
454 */
455function bindEvent(ctx: BindingContext, eventName: string, expr: string, modifiers: Modifier[] = []): void {
456 const executeHandler = (event: Event) => {
457 const eventScope: Scope = { ...ctx.scope, $el: ctx.element, $event: event };
458
459 try {
460 const statements = extractStatements(expr);
461 let result: unknown;
462 for (const stmt of statements) {
463 result = evaluate(stmt, eventScope, { unwrapSignals: false });
464 }
465
466 if (typeof result === "function") {
467 result(event);
468 }
469 } catch (error) {
470 report(error as Error, {
471 source: "binding",
472 element: ctx.element as HTMLElement,
473 directive: `data-volt-on-${eventName}`,
474 expression: expr,
475 });
476 }
477 };
478
479 let wrappedExecute = executeHandler;
480
481 if (hasModifier(modifiers, "debounce")) {
482 const wait = getModifierValue(modifiers, "debounce", 300);
483 const debouncedExecute = debounce(executeHandler, wait);
484 wrappedExecute = debouncedExecute as typeof executeHandler;
485 ctx.cleanups.push(() => debouncedExecute.cancel());
486 } else if (hasModifier(modifiers, "throttle")) {
487 const wait = getModifierValue(modifiers, "throttle", 300);
488 const throttledExecute = throttle(executeHandler, wait);
489 wrappedExecute = throttledExecute as typeof executeHandler;
490 ctx.cleanups.push(() => throttledExecute.cancel());
491 }
492
493 const handler = (event: Event) => {
494 if (hasModifier(modifiers, "self") && event.target !== ctx.element) {
495 return;
496 }
497
498 if (hasModifier(modifiers, "prevent")) {
499 event.preventDefault();
500 }
501
502 if (hasModifier(modifiers, "stop")) {
503 event.stopPropagation();
504 }
505
506 wrappedExecute(event);
507 };
508
509 const target = hasModifier(modifiers, "window")
510 ? globalThis
511 : (hasModifier(modifiers, "document") ? document : ctx.element);
512
513 const options: AddEventListenerOptions = {};
514 if (hasModifier(modifiers, "once")) {
515 options.once = true;
516 }
517 if (hasModifier(modifiers, "passive")) {
518 options.passive = true;
519 }
520
521 target.addEventListener(eventName, handler, options);
522
523 ctx.cleanups.push(() => {
524 target.removeEventListener(eventName, handler, options);
525 });
526}
527
528/**
529 * Get a nested property value from an object using a path array
530 *
531 * @example
532 * getNestedProperty({ user: { name: "Alice" } }, ["user", "name"]) // "Alice"
533 */
534function getNestedProperty(obj: unknown, path: string[]): unknown {
535 let current = obj;
536 for (const key of path) {
537 if (current == null || typeof current !== "object") {
538 return undefined;
539 }
540 current = (current as Record<string, unknown>)[key];
541 }
542 return current;
543}
544
545/**
546 * Set a nested property value in an object immutably using a path array
547 *
548 * @example
549 * setNestedProperty({ user: { name: "Alice" } }, ["user", "name"], "Bob")
550 * // Returns: { user: { name: "Bob" } }
551 */
552function setNestedProperty(obj: unknown, path: string[], value: unknown): unknown {
553 if (path.length === 0) {
554 return value;
555 }
556
557 if (obj == null || typeof obj !== "object") {
558 return obj;
559 }
560
561 const clone = Array.isArray(obj) ? [...obj] : { ...obj };
562 const [head, ...tail] = path;
563
564 if (tail.length === 0) {
565 (clone as Record<string, unknown>)[head] = value;
566 } else {
567 (clone as Record<string, unknown>)[head] = setNestedProperty((clone as Record<string, unknown>)[head], tail, value);
568 }
569
570 return clone;
571}
572
573/**
574 * Find a signal and optional nested property path for data-volt-model binding
575 *
576 * Supports two patterns:
577 * 1. Nested signals: { formData: { name: signal("") } } with path "formData.name"
578 * 2. Signal with object value: { formData: signal({ name: "" }) } with path "formData.name"
579 *
580 * @returns Object with signal and propertyPath, or null if not found
581 */
582function findModelSignal(scope: Scope, path: string): Nullable<{ signal: Signal<unknown>; propertyPath: string[] }> {
583 const signal = findScopedSignal(scope, path);
584 if (signal) {
585 return { signal, propertyPath: [] };
586 }
587
588 const parts = path.split(".");
589 for (let i = parts.length - 1; i > 0; i--) {
590 const prefix = parts.slice(0, i).join(".");
591 const testSignal = findScopedSignal(scope, prefix);
592
593 if (testSignal) {
594 const propertyPath = parts.slice(i);
595 return { signal: testSignal, propertyPath };
596 }
597 }
598
599 return null;
600}
601
602/**
603 * Bind data-volt-model for two-way data binding on form elements with support for modifiers.
604 * Syncs the signal value with the input value bidirectionally.
605 *
606 * Supported modifiers:
607 * - .number - coerces values to numbers
608 * - .trim - removes surrounding whitespace
609 * - .lazy - syncs on 'change' instead of 'input'
610 * - .debounce[.ms] - debounces signal updates (default 300ms)
611 */
612function bindModel(context: BindingContext, signalPath: string, modifiers: Modifier[] = []): void {
613 const result = findModelSignal(context.scope, signalPath);
614 if (!result) {
615 report(new Error(`Signal "${signalPath}" not found`), {
616 source: "binding",
617 element: context.element as HTMLElement,
618 directive: "data-volt-model",
619 expression: signalPath,
620 });
621 return;
622 }
623
624 const { signal, propertyPath } = result;
625
626 const element = context.element as FormControlElement;
627 const type = element instanceof HTMLInputElement ? element.type : null;
628
629 const getValue = (): unknown => {
630 const signalValue = signal.get();
631 return propertyPath.length > 0 ? getNestedProperty(signalValue, propertyPath) : signalValue;
632 };
633
634 const initialValue = getValue();
635 setElementValue(element, initialValue, type);
636
637 const unsubscribe = signal.subscribe(() => {
638 const value = getValue();
639 setElementValue(element, value, type);
640 });
641 context.cleanups.push(unsubscribe);
642
643 const isLazy = hasModifier(modifiers, "lazy");
644 const isNumber = hasModifier(modifiers, "number");
645 const isTrim = hasModifier(modifiers, "trim");
646
647 const defaultEventName = type === "checkbox" || type === "radio" ? "change" : "input";
648 const eventName = isLazy ? "change" : defaultEventName;
649
650 const baseHandler = () => {
651 let value = getElementValue(element, type);
652
653 if (typeof value === "string") {
654 if (isTrim) {
655 value = value.trim();
656 }
657 if (isNumber) {
658 value = value === "" ? Number.NaN : Number(value);
659 }
660 }
661
662 if (propertyPath.length > 0) {
663 const currentObj = signal.get();
664 const updatedObj = setNestedProperty(currentObj, propertyPath, value);
665 (signal as Signal<unknown>).set(updatedObj);
666 } else {
667 (signal as Signal<unknown>).set(value);
668 }
669 };
670
671 let handler = baseHandler;
672
673 if (hasModifier(modifiers, "debounce")) {
674 const wait = getModifierValue(modifiers, "debounce", 300);
675 const debouncedHandler = debounce(baseHandler, wait);
676 handler = debouncedHandler as typeof baseHandler;
677 context.cleanups.push(() => debouncedHandler.cancel());
678 }
679
680 element.addEventListener(eventName, handler);
681 context.cleanups.push(() => {
682 element.removeEventListener(eventName, handler);
683 });
684}
685
686function setElementValue(el: FormControlElement, value: unknown, type: string | null): void {
687 if (el instanceof HTMLInputElement) {
688 switch (type) {
689 case "checkbox": {
690 el.checked = Boolean(value);
691 break;
692 }
693 case "radio": {
694 el.checked = el.value === String(value);
695 break;
696 }
697 case "number": {
698 el.value = String(value ?? "");
699 break;
700 }
701 default: {
702 el.value = String(value ?? "");
703 }
704 }
705 } else if (el instanceof HTMLSelectElement) {
706 el.value = String(value ?? "");
707 } else if (el instanceof HTMLTextAreaElement) {
708 el.value = String(value ?? "");
709 }
710}
711
712function getElementValue(el: FormControlElement, type: string | null): unknown {
713 if (el instanceof HTMLInputElement) {
714 if (type === "checkbox") {
715 return el.checked;
716 }
717 if (type === "number") {
718 return el.valueAsNumber;
719 }
720 return el.value;
721 }
722
723 if (el instanceof HTMLSelectElement) {
724 return el.value;
725 }
726
727 if (el instanceof HTMLTextAreaElement) {
728 return el.value;
729 }
730
731 return "";
732}
733
734/**
735 * Bind data-volt-bind:attr for generic attribute binding with support for modifiers.
736 * Updates any HTML attribute reactively based on expression value.
737 *
738 * Supported modifiers:
739 * - .number - coerces values to numbers
740 * - .trim - removes surrounding whitespace
741 */
742function bindAttr(ctx: BindingContext, attrName: string, expr: string, modifiers: Modifier[] = []): void {
743 const isNumber = hasModifier(modifiers, "number");
744 const isTrim = hasModifier(modifiers, "trim");
745
746 const update = () => {
747 let value = evaluate(expr, ctx.scope);
748
749 if (typeof value === "string") {
750 if (isTrim) {
751 value = value.trim();
752 }
753 if (isNumber) {
754 value = value === "" ? Number.NaN : Number(value);
755 }
756 }
757
758 const booleanAttrs = new Set(BOOLEAN_ATTRS);
759
760 if (booleanAttrs.has(attrName)) {
761 if (value) {
762 ctx.element.setAttribute(attrName, "");
763 } else {
764 ctx.element.removeAttribute(attrName);
765 }
766 } else {
767 if (isNil(value) || value === false) {
768 ctx.element.removeAttribute(attrName);
769 } else {
770 ctx.element.setAttribute(attrName, String(value));
771 }
772 }
773 };
774
775 updateAndRegister(ctx, update, expr);
776}
777
778/**
779 * Bind data-volt-init to run initialization code once when the element is mounted.
780 */
781function bindInit(ctx: BindingContext, expr: string): void {
782 try {
783 const statements = extractStatements(expr);
784 for (const stmt of statements) {
785 evaluate(stmt, ctx.scope, { unwrapSignals: false });
786 }
787 } catch (error) {
788 report(error as Error, {
789 source: "binding",
790 element: ctx.element as HTMLElement,
791 directive: "data-volt-init",
792 expression: expr,
793 });
794 }
795}
796
797/**
798 * Bind data-volt-pin to register an element reference in the scope's pin registry.
799 * Makes the element accessible via $pins.name ($pins[name]) in expressions and event handlers.
800 *
801 * @example
802 * ```html
803 * <input data-volt-pin="username" />
804 * <button data-volt-on-click="$pins.username.focus()">Focus Input</button>
805 * ```
806 */
807function bindPin(ctx: BindingContext, name: string): void {
808 registerPin(ctx.scope, name, ctx.element);
809}
810
811/**
812 * Bind data-volt-for to render a list of items.
813 * Subscribes to array signal and re-renders when array changes.
814 */
815function bindFor(ctx: BindingContext, expr: string): void {
816 const parsed = parseForExpr(expr);
817 if (!parsed) {
818 report(new Error(`Invalid data-volt-for expression: "${expr}"`), {
819 source: "binding",
820 element: ctx.element as HTMLElement,
821 directive: "data-volt-for",
822 expression: expr,
823 });
824 return;
825 }
826
827 const { itemName, indexName, arrayPath } = parsed;
828 const templ = ctx.element as HTMLElement;
829 const parent = templ.parentElement;
830
831 if (!parent) {
832 report(new Error("data-volt-for element must have a parent"), {
833 source: "binding",
834 element: ctx.element as HTMLElement,
835 directive: "data-volt-for",
836 expression: expr,
837 });
838 return;
839 }
840
841 const placeholder = document.createComment(`for: ${expr}`);
842 templ.before(placeholder);
843 templ.remove();
844
845 const renderedElements: Element[] = [];
846 const renderedCleanups: CleanupFunction[] = [];
847
848 const render = () => {
849 for (const cleanup of renderedCleanups) {
850 cleanup();
851 }
852 renderedCleanups.length = 0;
853
854 for (const element of renderedElements) {
855 element.remove();
856 }
857 renderedElements.length = 0;
858
859 const arrayValue = evaluate(arrayPath, ctx.scope);
860 if (!Array.isArray(arrayValue)) {
861 return;
862 }
863
864 for (const [index, item] of arrayValue.entries()) {
865 const clone = templ.cloneNode(true) as Element;
866 delete (clone as HTMLElement).dataset.voltFor;
867
868 const itemScope: Scope = { ...ctx.scope, [itemName]: item };
869 if (indexName) {
870 itemScope[indexName] = index;
871 }
872
873 const cleanup = mount(clone, itemScope);
874 renderedCleanups.push(cleanup);
875 renderedElements.push(clone);
876
877 placeholder.before(clone);
878 }
879 };
880
881 updateAndRegister(ctx, render, expr);
882
883 ctx.cleanups.push(() => {
884 for (const cleanup of renderedCleanups) {
885 cleanup();
886 }
887 });
888}
889
890/**
891 * Bind data-volt-if to conditionally render an element. Supports data-volt-else on the next sibling element.
892 * Subscribes to condition signal and shows/hides elements when condition changes.
893 * Integrates with surge plugin for smooth enter/leave transitions when available.
894 */
895function bindIf(ctx: BindingContext, expr: string): void {
896 const ifTempl = ctx.element as HTMLElement;
897 const parent = ifTempl.parentElement;
898
899 if (!parent) {
900 report(new Error("data-volt-if element must have a parent"), {
901 source: "binding",
902 element: ctx.element as HTMLElement,
903 directive: "data-volt-if",
904 expression: expr,
905 });
906 return;
907 }
908
909 let elseTempl: Optional<HTMLElement>;
910 let nextSibling = ifTempl.nextElementSibling;
911
912 while (nextSibling && nextSibling.nodeType !== 1) {
913 nextSibling = nextSibling.nextElementSibling;
914 }
915
916 if (nextSibling && Object.hasOwn((nextSibling as HTMLElement).dataset, "voltElse")) {
917 elseTempl = nextSibling as HTMLElement;
918 elseTempl.remove();
919 }
920
921 const placeholder = document.createComment(`if: ${expr}`);
922 ifTempl.before(placeholder);
923 ifTempl.remove();
924
925 const ifHasSurge = hasSurge(ifTempl);
926 const elseHasSurge = elseTempl ? hasSurge(elseTempl) : false;
927 const anySurge = ifHasSurge || elseHasSurge;
928
929 let currentElement: Optional<Element>;
930 let currentCleanup: Optional<CleanupFunction>;
931 let currentBranch: Optional<"if" | "else">;
932 let isTransitioning = false;
933 let pendingRender = false;
934
935 const render = () => {
936 const condition = evaluate(expr, ctx.scope);
937 const shouldShow = Boolean(condition);
938
939 const targetBranch = shouldShow ? "if" : (elseTempl ? "else" : undefined);
940
941 if (targetBranch === currentBranch || isTransitioning) {
942 if (isTransitioning) {
943 pendingRender = true;
944 }
945 return;
946 }
947
948 if (!anySurge) {
949 if (currentCleanup) {
950 currentCleanup();
951 currentCleanup = undefined;
952 }
953 if (currentElement) {
954 currentElement.remove();
955 currentElement = undefined;
956 }
957
958 if (targetBranch === "if") {
959 currentElement = ifTempl.cloneNode(true) as Element;
960 delete (currentElement as HTMLElement).dataset.voltIf;
961 currentCleanup = mount(currentElement, ctx.scope);
962 placeholder.before(currentElement);
963 currentBranch = "if";
964 } else if (targetBranch === "else" && elseTempl) {
965 currentElement = elseTempl.cloneNode(true) as Element;
966 delete (currentElement as HTMLElement).dataset.voltElse;
967 currentCleanup = mount(currentElement, ctx.scope);
968 placeholder.before(currentElement);
969 currentBranch = "else";
970 } else {
971 currentBranch = undefined;
972 }
973 return;
974 }
975
976 isTransitioning = true;
977
978 void (async () => {
979 try {
980 if (currentElement) {
981 const currentEl = currentElement as HTMLElement;
982 const currentHasSurge = currentBranch === "if" ? ifHasSurge : elseHasSurge;
983
984 if (currentHasSurge) {
985 await executeSurgeLeave(currentEl);
986 }
987
988 if (currentCleanup) {
989 currentCleanup();
990 currentCleanup = undefined;
991 }
992 currentElement.remove();
993 currentElement = undefined;
994 }
995
996 if (targetBranch === "if") {
997 currentElement = ifTempl.cloneNode(true) as Element;
998 delete (currentElement as HTMLElement).dataset.voltIf;
999 placeholder.before(currentElement);
1000
1001 if (ifHasSurge) {
1002 await executeSurgeEnter(currentElement as HTMLElement);
1003 }
1004
1005 currentCleanup = mount(currentElement, ctx.scope);
1006 currentBranch = "if";
1007 } else if (targetBranch === "else" && elseTempl) {
1008 currentElement = elseTempl.cloneNode(true) as Element;
1009 delete (currentElement as HTMLElement).dataset.voltElse;
1010 placeholder.before(currentElement);
1011
1012 if (elseHasSurge) {
1013 await executeSurgeEnter(currentElement as HTMLElement);
1014 }
1015
1016 currentCleanup = mount(currentElement, ctx.scope);
1017 currentBranch = "else";
1018 } else {
1019 currentBranch = undefined;
1020 }
1021 } finally {
1022 isTransitioning = false;
1023 if (pendingRender) {
1024 pendingRender = false;
1025 render();
1026 }
1027 }
1028 })();
1029 };
1030
1031 updateAndRegister(ctx, render, expr);
1032
1033 ctx.cleanups.push(() => {
1034 if (currentCleanup) {
1035 currentCleanup();
1036 }
1037 });
1038}
1039
1040/**
1041 * Parse a data-volt-for expression
1042 * Supports: "item in items" or "(item, index) in items"
1043 */
1044function parseForExpr(expr: string): Optional<{ itemName: string; indexName?: string; arrayPath: string }> {
1045 const trimmed = expr.trim();
1046
1047 const withIndex = /^\((\w+)\s*,\s*(\w+)\)\s+in\s+(.+)$/.exec(trimmed);
1048 if (withIndex) {
1049 return { itemName: withIndex[1], indexName: withIndex[2], arrayPath: withIndex[3].trim() };
1050 }
1051
1052 const simple = /^(\w+)\s+in\s+(.+)$/.exec(trimmed);
1053 if (simple) {
1054 return { itemName: simple[1], indexName: undefined, arrayPath: simple[2].trim() };
1055 }
1056
1057 return undefined;
1058}
1059
1060/**
1061 * Create a plugin context from a binding context.
1062 * Provides the plugin with access to utilities and cleanup registration.
1063 */
1064function createPluginCtx(ctx: BindingContext): PluginContext {
1065 const mountCallbacks: Array<() => void> = [];
1066 const unmountCallbacks: Array<() => void> = [];
1067 const beforeBindingCallbacks: Array<() => void> = [];
1068 const afterBindingCallbacks: Array<() => void> = [];
1069
1070 const lifecycle = {
1071 onMount: (cb: () => void) => {
1072 mountCallbacks.push(cb);
1073 try {
1074 cb();
1075 } catch (error) {
1076 report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "onMount" });
1077 }
1078 },
1079 onUnmount: (cb: () => void) => {
1080 unmountCallbacks.push(cb);
1081 },
1082 beforeBinding: (cb: () => void) => {
1083 beforeBindingCallbacks.push(cb);
1084 try {
1085 cb();
1086 } catch (error) {
1087 report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "beforeBinding" });
1088 }
1089 },
1090 afterBinding: (cb: () => void) => {
1091 afterBindingCallbacks.push(cb);
1092 queueMicrotask(() => {
1093 try {
1094 cb();
1095 } catch (error) {
1096 report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "afterBinding" });
1097 }
1098 });
1099 },
1100 };
1101
1102 ctx.cleanups.push(() => {
1103 for (const cb of unmountCallbacks) {
1104 try {
1105 cb();
1106 } catch (error) {
1107 report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "onUnmount" });
1108 }
1109 }
1110 });
1111
1112 return {
1113 element: ctx.element,
1114 scope: ctx.scope,
1115 addCleanup: (fn) => {
1116 ctx.cleanups.push(fn);
1117 },
1118 findSignal: (path) => findScopedSignal(ctx.scope, path),
1119 evaluate: (expr, options) => evaluate(expr, ctx.scope, options),
1120 lifecycle,
1121 };
1122}
1123
1124/**
1125 * Inject special variables ($store, $origin, $scope, $pins, $pulse, $uid, $arc, $probe)
1126 * into the scope for this root element.
1127 *
1128 * Creates scope metadata and makes runtime utilities available in expressions.
1129 * We create a Proxy for $pins that dynamically reads from metadata to ensure pins registered later are immediately accessible
1130 */
1131function injectSpecialVars(scope: Scope, root: Element): void {
1132 createScopeMetadata(scope, root);
1133
1134 scope.$store = getStore();
1135 scope.$pulse = createPulse();
1136 scope.$origin = root;
1137 scope.$scope = scope;
1138
1139 scope.$pins = new Proxy({}, {
1140 get(_target, prop: string) {
1141 if (typeof prop === "string") {
1142 return getPin(scope, prop);
1143 }
1144 return void 0;
1145 },
1146 has(_target, prop: string) {
1147 if (typeof prop === "string") {
1148 return getPin(scope, prop) !== undefined;
1149 }
1150 return false;
1151 },
1152 });
1153
1154 scope.$uid = createUid(scope);
1155 scope.$arc = createArc(root);
1156 scope.$probe = createProbe(scope);
1157}