a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 4.9 kB view raw
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}