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