a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1/**
2 * @packageDocumentation Shared module
3 *
4 * functions exported from this module should only depend on types and other helpers
5 */
6import type { None, Optional } from "$types/helpers";
7import type { BindingContext, Dep, Scope, Signal } from "$types/volt";
8
9export function kebabToCamel(str: string): string {
10 return str.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
11}
12
13/**
14 * Check if a value is null or undefined ({@link None}).
15 */
16export function isNil(value: unknown): value is None {
17 return value === null || value === undefined;
18}
19
20export function isSignal(value: unknown): value is Dep {
21 return (typeof value === "object"
22 && value !== null
23 && "get" in value
24 && "subscribe" in value
25 && typeof value.get === "function"
26 && typeof (value as { subscribe: unknown }).subscribe === "function");
27}
28
29export function findScopedSignal(scope: Scope, path: string): Optional<Signal<unknown>> {
30 const trimmed = path.trim();
31 if (!trimmed) {
32 return undefined;
33 }
34
35 const parts = trimmed.split(".");
36 let current: unknown = scope;
37
38 for (const part of parts) {
39 if (isNil(current) || typeof current !== "object") {
40 return undefined;
41 }
42
43 const record = current as Record<string, unknown>;
44
45 if (Object.hasOwn(record, part)) {
46 current = record[part];
47 continue;
48 }
49
50 const camelCandidate = kebabToCamel(part);
51 if (Object.hasOwn(record, camelCandidate)) {
52 current = record[camelCandidate];
53 continue;
54 }
55
56 const lowerPart = part.toLowerCase();
57 const matchedKey = Object.keys(record).find((key) => key.toLowerCase() === lowerPart);
58 if (!matchedKey) {
59 return undefined;
60 }
61
62 current = record[matchedKey];
63 }
64
65 if (isSignal(current)) {
66 return current as Signal<unknown>;
67 }
68
69 return undefined;
70}
71
72/**
73 * Get all data-volt-computed:name attributes from an element.
74 * Converts kebab-case names to camelCase to match JS conventions.
75 */
76export function getComputedAttributes(el: Element): Map<string, string> {
77 const computed = new Map<string, string>();
78
79 for (const attr of el.attributes) {
80 if (attr.name.startsWith("data-volt-computed:")) {
81 const name = attr.name.slice("data-volt-computed:".length);
82 const camelName = kebabToCamel(name);
83 computed.set(camelName, attr.value);
84 }
85 }
86
87 return computed;
88}
89
90/**
91 * Sleep for a specified duration in ms
92 */
93export function sleep(ms: number): Promise<void> {
94 return new Promise((resolve) => setTimeout(resolve, ms));
95}
96
97/**
98 * Extract all signal dependencies from an expression by finding identifiers that correspond to signals in the scope.
99 *
100 * This function handles both simple property paths (e.g., "todo.title") and complex expressions (e.g., "email.length > 0 && emailValid").
101 * It also handles special $store.get() and $store.set() calls by extracting the key and finding the underlying signal.
102 *
103 * @param expr - The expression to analyze
104 * @param scope - The scope containing potential signal dependencies
105 * @returns Array of signals found in the expression
106 */
107export function extractDeps(expr: string, scope: Scope): Array<Dep> {
108 const deps: Array<Dep> = [];
109 const seen = new Set<string>();
110 const storeCalls = expr.matchAll(/\$store\.(get|set|has)\s*\(\s*['"]([^'"]+)['"]\s*(?:,|\))/g);
111
112 for (const match of storeCalls) {
113 const key = match[2];
114 const storeKey = `$store.${key}`;
115
116 if (seen.has(storeKey)) {
117 continue;
118 }
119
120 seen.add(storeKey);
121
122 const store = scope.$store;
123 if (store && typeof store === "object" && "_signals" in store) {
124 const storeSignals = store._signals as Map<string, Signal<unknown>>;
125 const signal = storeSignals.get(key);
126 if (signal && !deps.includes(signal)) {
127 deps.push(signal);
128 }
129 }
130 }
131
132 const matches = expr.matchAll(/\b([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)\b/g);
133
134 for (const match of matches) {
135 const path = match[1];
136
137 if (["true", "false", "null", "undefined"].includes(path)) {
138 continue;
139 }
140
141 if (seen.has(path)) {
142 continue;
143 }
144
145 seen.add(path);
146
147 const signal = findScopedSignal(scope, path);
148 if (signal) {
149 deps.push(signal);
150 continue;
151 }
152
153 const parts = path.split(".");
154 const topLevel = parts[0];
155 const value = scope[topLevel];
156 if (isSignal(value) && !deps.includes(value)) {
157 deps.push(value);
158 }
159 }
160
161 return deps;
162}
163
164/**
165 * Helper function to execute an update function and subscribe to all signal dependencies.
166 * Used by bindings that need reactive updates (class, show, style, for, if) to register cleanup functions.
167 */
168export function updateAndRegister(ctx: BindingContext, update: () => void, expr: string) {
169 update();
170 const deps = extractDeps(expr, ctx.scope);
171 for (const dep of deps) {
172 const unsubscribe = dep.subscribe(update);
173 ctx.cleanups.push(unsubscribe);
174 }
175}