a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1/**
2 * Route utilities for pattern matching and parameter extraction
3 *
4 * Provides utilities for dynamic route matching with support for:
5 * - Named parameters: /blog/:slug
6 * - Wildcard parameters: /files/*path
7 * - Optional parameters: /blog/:slug?
8 * - Multiple parameters: /users/:userId/posts/:postId
9 */
10
11import type { Optional } from "$types/helpers";
12
13/**
14 * Route match result containing extracted parameters
15 */
16export type RouteMatch = { path: string; params: Record<string, string>; pattern: string };
17
18/**
19 * Compiled route pattern for efficient matching
20 */
21type CompiledRoute = {
22 pattern: string;
23 regex: RegExp;
24 keys: Array<{ name: string; optional: boolean; wildcard: boolean }>;
25};
26
27const routeCache = new Map<string, CompiledRoute>();
28
29/**
30 * Compile a route pattern into a regex for efficient matching
31 *
32 * Supported patterns:
33 * - /blog/:slug - Named parameter
34 * - /blog/:slug? - Optional parameter
35 * - /files/*path - Wildcard (matches rest of path)
36 * - /users/:userId/posts/:postId - Multiple parameters
37 *
38 * @param pattern - Route pattern to compile
39 * @returns Compiled route with regex and parameter keys
40 *
41 * @example
42 * ```typescript
43 * const route = compileRoute('/blog/:slug');
44 * const match = route.regex.exec('/blog/hello-world');
45 * // match[1] === 'hello-world'
46 * ```
47 */
48export function compileRoute(pattern: string): CompiledRoute {
49 if (routeCache.has(pattern)) {
50 return routeCache.get(pattern)!;
51 }
52
53 const keys: Array<{ name: string; optional: boolean; wildcard: boolean }> = [];
54
55 // Build regex pattern by processing each part
56 let regexPattern = "";
57 let i = 0;
58
59 while (i < pattern.length) {
60 // Check for parameter :name or :name?
61 if (pattern[i] === ":") {
62 const paramMatch = pattern.slice(i).match(/^:(\w+)(\?)?/);
63 if (paramMatch) {
64 const [fullMatch, name, optional] = paramMatch;
65 keys.push({ name, optional: Boolean(optional), wildcard: false });
66
67 if (optional) {
68 // For optional params, include the preceding / in the optional group
69 // Remove trailing / from regexPattern if present
70 if (regexPattern.endsWith("/")) {
71 regexPattern = regexPattern.slice(0, -1);
72 }
73 regexPattern += "(?:/([^/?]+))?";
74 } else {
75 // Required params: just the capture group (/ already processed)
76 regexPattern += "([^/?]+)";
77 }
78
79 i += fullMatch.length;
80 continue;
81 }
82 }
83
84 // Check for wildcard *name
85 if (pattern[i] === "*") {
86 const wildcardMatch = pattern.slice(i).match(/^\*(\w+)/);
87 if (wildcardMatch) {
88 const [fullMatch, name] = wildcardMatch;
89 keys.push({ name, optional: false, wildcard: true });
90 regexPattern += "(.*)";
91 i += fullMatch.length;
92 continue;
93 }
94 }
95
96 // Escape special regex characters for literal matching
97 const char = pattern[i];
98 if (".+?^${}()|[]\\".includes(char)) {
99 regexPattern += `\\${char}`;
100 } else {
101 regexPattern += char;
102 }
103 i++;
104 }
105
106 // Create regex with anchors
107 const regex = new RegExp(`^${regexPattern}$`);
108
109 const compiled: CompiledRoute = { pattern, regex, keys };
110 routeCache.set(pattern, compiled);
111
112 return compiled;
113}
114
115/**
116 * Match a path against a route pattern and extract parameters
117 *
118 * @param pattern - Route pattern (e.g., '/blog/:slug')
119 * @param path - Path to match (e.g., '/blog/hello-world')
120 * @returns RouteMatch with extracted params, or undefined if no match
121 *
122 * @example
123 * ```typescript
124 * const match = matchRoute('/blog/:slug', '/blog/hello-world');
125 * // { path: '/blog/hello-world', params: { slug: 'hello-world' }, pattern: '/blog/:slug' }
126 *
127 * const noMatch = matchRoute('/blog/:slug', '/about');
128 * // undefined
129 * ```
130 */
131export function matchRoute(pattern: string, path: string): Optional<RouteMatch> {
132 const compiled = compileRoute(pattern);
133 const match = compiled.regex.exec(path);
134
135 if (!match) {
136 return undefined;
137 }
138
139 const params: Record<string, string> = {};
140
141 for (const [index, key] of compiled.keys.entries()) {
142 const value = match[index + 1];
143 if (value !== undefined) {
144 params[key.name] = decodeURIComponent(value);
145 }
146 }
147
148 return { path, params, pattern };
149}
150
151/**
152 * Match a path against multiple route patterns and return the first match
153 *
154 * @param patterns - Array of route patterns to try
155 * @param path - Path to match
156 * @returns First matching RouteMatch, or undefined if no match
157 *
158 * @example
159 * ```typescript
160 * const routes = ['/blog/:slug', '/users/:id', '/about'];
161 * const match = matchRoutes(routes, '/users/123');
162 * // { path: '/users/123', params: { id: '123' }, pattern: '/users/:id' }
163 * ```
164 */
165export function matchRoutes(patterns: string[], path: string): Optional<RouteMatch> {
166 for (const pattern of patterns) {
167 const match = matchRoute(pattern, path);
168 if (match) {
169 return match;
170 }
171 }
172 return undefined;
173}
174
175/**
176 * Extract parameters from a path using a route pattern
177 *
178 * @param pattern - Route pattern with parameters
179 * @param path - Path to extract from
180 * @returns Object with extracted parameters, or empty object if no match
181 *
182 * @example
183 * ```typescript
184 * const params = extractParams('/blog/:slug', '/blog/hello-world');
185 * // { slug: 'hello-world' }
186 *
187 * const params2 = extractParams('/users/:userId/posts/:postId', '/users/42/posts/123');
188 * // { userId: '42', postId: '123' }
189 * ```
190 */
191export function extractParams(pattern: string, path: string): Record<string, string> {
192 const match = matchRoute(pattern, path);
193 return match ? match.params : {};
194}
195
196/**
197 * Build a path from a pattern by replacing parameters
198 *
199 * @param pattern - Route pattern with parameters
200 * @param params - Parameters to insert
201 * @returns Built path with parameters replaced
202 *
203 * @example
204 * ```typescript
205 * const path = buildPath('/blog/:slug', { slug: 'hello-world' });
206 * // '/blog/hello-world'
207 *
208 * const path2 = buildPath('/users/:userId/posts/:postId', { userId: '42', postId: '123' });
209 * // '/users/42/posts/123'
210 * ```
211 */
212export function buildPath(pattern: string, params: Record<string, string>): string {
213 let path = pattern;
214
215 // Replace named parameters
216 for (const [key, value] of Object.entries(params)) {
217 const encoded = encodeURIComponent(value);
218 path = path.replace(`:${key}?`, encoded).replace(`:${key}`, encoded);
219 }
220
221 // Remove optional parameters that weren't provided
222 path = path.replaceAll(/:(\w+)\?/g, "");
223
224 // Replace wildcards
225 for (const [key, value] of Object.entries(params)) {
226 path = path.replace(`*${key}`, value);
227 }
228
229 return path;
230}
231
232/**
233 * Check if a path matches a route pattern
234 *
235 * @param pattern - Route pattern
236 * @param path - Path to check
237 * @returns true if path matches pattern
238 *
239 * @example
240 * ```typescript
241 * isMatch('/blog/:slug', '/blog/hello-world'); // true
242 * isMatch('/blog/:slug', '/about'); // false
243 * ```
244 */
245export function isMatch(pattern: string, path: string): boolean {
246 return matchRoute(pattern, path) !== undefined;
247}
248
249/**
250 * Normalize a path by removing trailing slashes and ensuring leading slash
251 *
252 * @param path - Path to normalize
253 * @returns Normalized path
254 *
255 * @example
256 * ```typescript
257 * normalizePath('/blog/'); // '/blog'
258 * normalizePath('about'); // '/about'
259 * normalizePath('/'); // '/'
260 * ```
261 */
262export function normalizePath(path: string): string {
263 // Ensure leading slash
264 if (!path.startsWith("/")) {
265 path = `/${path}`;
266 }
267
268 // Remove trailing slash (except for root)
269 if (path.length > 1 && path.endsWith("/")) {
270 path = path.slice(0, -1);
271 }
272
273 return path;
274}
275
276/**
277 * Parse a URL into path and search params
278 *
279 * @param url - URL to parse (can be relative or absolute)
280 * @returns Object with path and searchParams
281 *
282 * @example
283 * ```typescript
284 * parseUrl('/blog?page=2&sort=date');
285 * // { path: '/blog', searchParams: URLSearchParams { 'page' => '2', 'sort' => 'date' } }
286 * ```
287 */
288export function parseUrl(url: string): { path: string; searchParams: URLSearchParams } {
289 try {
290 const urlObj = new URL(url, globalThis.location.origin);
291 return { path: urlObj.pathname, searchParams: urlObj.searchParams };
292 } catch {
293 // If URL parsing fails, treat as relative path
294 const [path, search] = url.split("?");
295 return { path: path || "/", searchParams: new URLSearchParams(search || "") };
296 }
297}
298
299/**
300 * Clear the route compilation cache
301 * Useful for testing or when patterns change dynamically
302 */
303export function clearRouteCache(): void {
304 routeCache.clear();
305}