a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 11 kB view raw
1/** 2 * URL plugin for synchronizing signals with URL parameters and hash routing 3 * Supports one-way read, bidirectional sync, and hash-based routing 4 */ 5 6import { isNil, kebabToCamel } from "$core/shared"; 7import type { Optional } from "$types/helpers"; 8import type { PluginContext, Scope, Signal } from "$types/volt"; 9 10type UrlMode = "read" | "sync" | "hash" | "history"; 11 12interface ResolvedSignal<T = unknown> { 13 path: string; 14 signal: Signal<T>; 15} 16 17function normalizeMode(mode: string): Optional<UrlMode> { 18 const normalized = mode.trim().toLowerCase().replaceAll(/[\s_-]/g, ""); 19 20 switch (normalized) { 21 case "read": { 22 return "read"; 23 } 24 case "sync": 25 case "bidirectional": { 26 return "sync"; 27 } 28 case "query": 29 case "search": { 30 return "sync"; 31 } 32 case "hash": { 33 return "hash"; 34 } 35 case "history": 36 case "route": { 37 return "history"; 38 } 39 default: { 40 return undefined; 41 } 42 } 43} 44 45function resolveCanonicalPath(scope: Scope, rawPath: string): string { 46 const trimmed = rawPath.trim(); 47 if (!trimmed) { 48 return trimmed; 49 } 50 51 const parts = trimmed.split("."); 52 const resolved: string[] = []; 53 let current: unknown = scope; 54 55 for (const part of parts) { 56 if (isNil(current) || typeof current !== "object") { 57 resolved.push(part); 58 current = undefined; 59 continue; 60 } 61 62 const record = current as Record<string, unknown>; 63 64 if (Object.hasOwn(record, part)) { 65 resolved.push(part); 66 current = record[part]; 67 continue; 68 } 69 70 const camelCandidate = kebabToCamel(part); 71 if (Object.hasOwn(record, camelCandidate)) { 72 resolved.push(camelCandidate); 73 current = record[camelCandidate]; 74 continue; 75 } 76 77 const lower = part.toLowerCase(); 78 const matchedKey = Object.keys(record).find((key) => key.toLowerCase() === lower); 79 80 if (matchedKey) { 81 resolved.push(matchedKey); 82 current = record[matchedKey]; 83 continue; 84 } 85 86 resolved.push(part); 87 current = undefined; 88 } 89 90 return resolved.join("."); 91} 92 93function resolveSignal(ctx: PluginContext, rawPath: string): Optional<ResolvedSignal> { 94 const trimmed = rawPath.trim(); 95 if (!trimmed) { 96 return undefined; 97 } 98 99 const canonicalPath = resolveCanonicalPath(ctx.scope, trimmed); 100 const candidatePaths = new Set([canonicalPath, trimmed]); 101 102 for (const candidate of candidatePaths) { 103 const found = ctx.findSignal(candidate); 104 if (found) { 105 return { path: candidate, signal: found as Signal<unknown> }; 106 } 107 } 108 109 return undefined; 110} 111 112/** 113 * URL plugin handler. 114 * Synchronizes signal values with URL parameters, hash, and full history state. 115 * 116 * Syntax: data-volt-url="mode:signalPath" or data-volt-url="mode:signalPath:basePath" 117 * Alternate syntax: data-volt-url:signalPath="mode" (e.g., data-volt-url:search="query") 118 * Modes: 119 * - read:signalPath - Read URL param into signal on mount (one-way) 120 * - sync:signalPath - Bidirectional sync between signal and URL param 121 * - hash:signalPath - Sync with hash portion for routing 122 * - history:signalPath[:basePath] - Sync with full path + search (History API routing) 123 */ 124export function urlPlugin(ctx: PluginContext, value: string): void { 125 const parts = value.split(":").map((part) => part.trim()).filter((part) => part.length > 0); 126 if (parts.length < 2) { 127 console.error( 128 `Invalid url binding: "${value}". Expected format: "mode:signalPath[:basePath]" or "signalPath:mode[:basePath]"`, 129 ); 130 return; 131 } 132 133 const firstMode = normalizeMode(parts[0]); 134 const secondMode = normalizeMode(parts[1] ?? ""); 135 136 let mode: Optional<UrlMode>; 137 let signalPath: string; 138 let basePath: Optional<string>; 139 140 if (firstMode) { 141 mode = firstMode; 142 signalPath = parts[1] ?? ""; 143 basePath = parts.slice(2).join(":") || undefined; 144 } else if (secondMode) { 145 mode = secondMode; 146 signalPath = parts[0]; 147 basePath = parts.slice(2).join(":") || undefined; 148 } else { 149 console.error(`Unknown url mode in binding "${value}"`); 150 return; 151 } 152 153 if (!signalPath) { 154 console.error(`Signal path missing for url binding "${value}"`); 155 return; 156 } 157 158 const resolvedSignal = resolveSignal(ctx, signalPath); 159 if (!resolvedSignal) { 160 console.error(`Signal "${signalPath}" not found for url binding`); 161 return; 162 } 163 164 switch (mode) { 165 case "read": { 166 handleReadURL(resolvedSignal); 167 break; 168 } 169 case "sync": { 170 handleSyncURL(ctx, resolvedSignal); 171 break; 172 } 173 case "hash": { 174 handleHashRouting(ctx, resolvedSignal as ResolvedSignal<string>); 175 break; 176 } 177 case "history": { 178 handleHistoryRouting(ctx, resolvedSignal as ResolvedSignal<string>, basePath); 179 break; 180 } 181 } 182} 183 184/** 185 * Read URL parameter into signal on mount (one-way). 186 * Signal changes do not update URL. 187 */ 188function handleReadURL(resolved: ResolvedSignal): void { 189 const params = new URLSearchParams(globalThis.location.search); 190 const paramValue = params.get(resolved.path); 191 192 if (paramValue !== null) { 193 resolved.signal.set(deserializeValue(paramValue)); 194 } 195} 196 197/** 198 * Bidirectional sync between signal and URL parameter. 199 * Changes to either the signal or URL update the other. 200 */ 201function handleSyncURL(ctx: PluginContext, resolved: ResolvedSignal): void { 202 const params = new URLSearchParams(globalThis.location.search); 203 const paramValue = params.get(resolved.path); 204 if (paramValue !== null) { 205 resolved.signal.set(deserializeValue(paramValue)); 206 } 207 208 let isUpdatingFromUrl = false; 209 let updateTimeout: Optional<number>; 210 211 const updateUrl = (value: unknown) => { 212 if (isUpdatingFromUrl) { 213 return; 214 } 215 216 if (updateTimeout) { 217 clearTimeout(updateTimeout); 218 } 219 220 updateTimeout = setTimeout(() => { 221 const params = new URLSearchParams(globalThis.location.search); 222 const serialized = serializeValue(value); 223 224 if (isNil(serialized) || serialized === "") { 225 params.delete(resolved.path); 226 } else { 227 params.set(resolved.path, serialized); 228 } 229 230 const newSearch = params.toString(); 231 const newUrl = newSearch ? `?${newSearch}` : globalThis.location.pathname; 232 233 globalThis.history.pushState({}, "", newUrl); 234 }, 100) as unknown as number; 235 }; 236 237 const handlePopState = () => { 238 isUpdatingFromUrl = true; 239 const params = new URLSearchParams(globalThis.location.search); 240 const paramValue = params.get(resolved.path); 241 242 if (isNil(paramValue)) { 243 resolved.signal.set(""); 244 } else { 245 resolved.signal.set(deserializeValue(paramValue)); 246 } 247 isUpdatingFromUrl = false; 248 }; 249 250 const unsubscribe = resolved.signal.subscribe(updateUrl); 251 globalThis.addEventListener("popstate", handlePopState); 252 253 ctx.addCleanup(() => { 254 unsubscribe(); 255 globalThis.removeEventListener("popstate", handlePopState); 256 if (updateTimeout) { 257 clearTimeout(updateTimeout); 258 } 259 }); 260} 261 262/** 263 * Sync signal with hash portion of URL for client-side routing. 264 * Bidirectional sync between signal and window.location.hash. 265 */ 266function handleHashRouting(ctx: PluginContext, resolved: ResolvedSignal<string>): void { 267 const currentHash = globalThis.location.hash.slice(1); 268 if (currentHash) { 269 resolved.signal.set(currentHash); 270 } 271 272 let isUpdatingFromHash = false; 273 274 const updateHash = (value: unknown) => { 275 if (isUpdatingFromHash) { 276 return; 277 } 278 279 const hashValue = String(value ?? ""); 280 const newHash = hashValue ? `#${hashValue}` : ""; 281 282 if (globalThis.location.hash !== newHash) { 283 globalThis.history.pushState({}, "", newHash || globalThis.location.pathname); 284 } 285 }; 286 287 const handleHashChange = () => { 288 isUpdatingFromHash = true; 289 const currentHash = globalThis.location.hash.slice(1); 290 resolved.signal.set(currentHash); 291 isUpdatingFromHash = false; 292 }; 293 294 const unsubscribe = resolved.signal.subscribe(updateHash); 295 globalThis.addEventListener("hashchange", handleHashChange); 296 297 ctx.addCleanup(() => { 298 unsubscribe(); 299 globalThis.removeEventListener("hashchange", handleHashChange); 300 }); 301} 302 303/** 304 * Serialize a value for URL parameter storage. 305 * Handles strings, numbers, booleans, and No Value (null/undefined). 306 */ 307function serializeValue(value: unknown): string { 308 if (isNil(value)) { 309 return ""; 310 } 311 if (typeof value === "string") { 312 return value; 313 } 314 if (typeof value === "number" || typeof value === "boolean") { 315 return String(value); 316 } 317 return JSON.stringify(value); 318} 319 320/** 321 * Deserialize a URL parameter value by attempting to parse as JSON, falls back to string. 322 */ 323function deserializeValue(value: string): unknown { 324 if (value === "true") return true; 325 if (value === "false") return false; 326 if (value === "null") return null; 327 if (value === "undefined") return undefined; 328 329 const numberValue = Number(value); 330 if (!Number.isNaN(numberValue) && value !== "") { 331 return numberValue; 332 } 333 334 try { 335 return JSON.parse(value); 336 } catch { 337 return value; 338 } 339} 340 341function normalizeRoute(path: string) { 342 if (!path) { 343 return "/"; 344 } 345 return path.startsWith("/") ? path : `/${path}`; 346} 347 348/** 349 * Sync signal with full path + search params for History API routing. 350 * Bidirectional sync between signal and window.location.pathname + search. 351 */ 352function handleHistoryRouting(ctx: PluginContext, resolved: ResolvedSignal<string>, basePath?: string): void { 353 const base = basePath?.trim() ?? ""; 354 355 const extractRoute = () => { 356 const fullPath = globalThis.location.pathname + globalThis.location.search; 357 if (base && fullPath.startsWith(base)) { 358 const stripped = fullPath.slice(base.length) || "/"; 359 return normalizeRoute(stripped); 360 } 361 return normalizeRoute(fullPath); 362 }; 363 364 const currentRoute = extractRoute(); 365 resolved.signal.set(currentRoute); 366 367 let isUpdatingFromHistory = false; 368 369 const updateUrl = (value: unknown) => { 370 if (isUpdatingFromHistory) { 371 return; 372 } 373 374 const route = normalizeRoute(String(value ?? "/")); 375 const fullPath = base ? `${base}${route}` : route; 376 const currentFull = globalThis.location.pathname + globalThis.location.search; 377 378 if (currentFull !== fullPath) { 379 globalThis.history.pushState({}, "", fullPath); 380 globalThis.dispatchEvent( 381 new CustomEvent("volt:navigate", { detail: { url: fullPath, route }, bubbles: true, cancelable: false }), 382 ); 383 } 384 }; 385 386 const handlePopState = () => { 387 isUpdatingFromHistory = true; 388 const route = extractRoute(); 389 resolved.signal.set(route); 390 globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { route }, bubbles: true, cancelable: false })); 391 isUpdatingFromHistory = false; 392 }; 393 394 const handleNavigate = () => { 395 isUpdatingFromHistory = true; 396 resolved.signal.set(extractRoute()); 397 isUpdatingFromHistory = false; 398 }; 399 400 const unsubscribe = resolved.signal.subscribe(updateUrl); 401 globalThis.addEventListener("popstate", handlePopState); 402 globalThis.addEventListener("volt:navigate", handleNavigate); 403 404 ctx.addCleanup(() => { 405 unsubscribe(); 406 globalThis.removeEventListener("popstate", handlePopState); 407 globalThis.removeEventListener("volt:navigate", handleNavigate); 408 }); 409}