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