sveltekit-routing.ts
620 lines 16 kB view raw
1import fs from 'node:fs'; 2import path from 'node:path'; 3 4export interface RouteParam { 5 name: string; 6 matcher?: string; 7 optional: boolean; 8 rest: boolean; 9 chained: boolean; 10} 11 12export interface RouteData { 13 id: string; 14 parent: RouteData | null; 15 segment: string; 16 pattern: RegExp; 17 params: RouteParam[]; 18 layout: PageNode | null; 19 leaf: PageNode | null; 20 page: { 21 layouts: Array<number | undefined>; 22 leaf: number; 23 } | null; 24} 25 26export interface PageNode { 27 depth: number; 28 component?: string; 29 parent_id?: string; 30 parent?: PageNode; 31 child_pages?: PageNode[]; 32} 33 34export interface RoutingConfig { 35 routes_dir: string; 36 extensions: string[]; 37 cwd?: string; 38} 39 40const param_pattern = /^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/; 41 42function parse_route_id(id: string): { pattern: RegExp; params: RouteParam[] } { 43 const params: RouteParam[] = []; 44 45 const pattern = 46 id === '/' 47 ? /^\/$/ 48 : new RegExp( 49 `^${get_route_segments(id) 50 .map((segment) => { 51 // special case — /[...rest]/ could contain zero segments 52 const rest_match = /^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(segment); 53 if (rest_match) { 54 params.push({ 55 name: rest_match[1], 56 matcher: rest_match[2], 57 optional: false, 58 rest: true, 59 chained: true, 60 }); 61 return '(?:/([^]*))?'; 62 } 63 64 // special case — /[[optional]]/ could contain zero segments 65 const optional_match = /^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(segment); 66 if (optional_match) { 67 params.push({ 68 name: optional_match[1], 69 matcher: optional_match[2], 70 optional: true, 71 rest: false, 72 chained: true, 73 }); 74 return '(?:/([^/]+))?'; 75 } 76 77 if (!segment) { 78 return; 79 } 80 81 const parts = segment.split(/\[(.+?)\](?!\])/); 82 const result = parts 83 .map((content, i) => { 84 if (i % 2) { 85 if (content.startsWith('x+')) { 86 return escape(String.fromCharCode(parseInt(content.slice(2), 16))); 87 } 88 89 if (content.startsWith('u+')) { 90 return escape( 91 String.fromCharCode( 92 ...content 93 .slice(2) 94 .split('-') 95 .map((code) => parseInt(code, 16)), 96 ), 97 ); 98 } 99 100 const match = param_pattern.exec(content); 101 if (!match) { 102 throw new Error( 103 `Invalid param: ${content}. Params and matcher names can only have underscores and alphanumeric characters.`, 104 ); 105 } 106 107 const [, is_optional, is_rest, name, matcher] = match; 108 109 params.push({ 110 name, 111 matcher, 112 optional: !!is_optional, 113 rest: !!is_rest, 114 chained: is_rest ? i === 1 && parts[0] === '' : false, 115 }); 116 return is_rest ? '([^]*?)' : is_optional ? '([^/]*)?' : '([^/]+?)'; 117 } 118 119 return escape(content); 120 }) 121 .join(''); 122 123 return '/' + result; 124 }) 125 .join('')}/?$`, 126 ); 127 128 return { pattern, params }; 129} 130 131/** 132 * Returns false for (group) segments 133 */ 134function affects_path(segment: string): boolean { 135 return segment !== '' && !/^\([^)]+\)$/.test(segment); 136} 137 138/** 139 * Splits a route id into its segments, removing segments that 140 * don't affect the path (i.e. groups). The root route is represented by `/` 141 * and will be returned as `['']`. 142 */ 143function get_route_segments(route: string): string[] { 144 return route.slice(1).split('/').filter(affects_path); 145} 146 147/** 148 * Populate a route ID with params to resolve a pathname. 149 * @example 150 * resolveRoute('/blog/[slug]/[...rest]', { slug: 'hello', rest: 'world/foo' }) 151 * // returns '/blog/hello/world/foo' 152 */ 153export function resolve_route(id: string, params: Record<string, string | undefined>): string { 154 const segments = get_route_segments(id); 155 const has_id_trailing_slash = id !== '/' && id.endsWith('/'); 156 const basic_param_pattern = /\[(\[)?(\.\.\.)?(\w+?)(?:=(\w+))?\]\]?/g; 157 158 return ( 159 '/' + 160 segments 161 .map((segment) => 162 segment.replace(basic_param_pattern, (_, optional, rest, name) => { 163 const param_value = params[name]; 164 165 if (!param_value) { 166 if (optional) return ''; 167 if (rest && param_value !== undefined) return ''; 168 throw new Error(`Missing parameter '${name}' in route ${id}`); 169 } 170 171 if (param_value.startsWith('/') || param_value.endsWith('/')) 172 throw new Error(`Parameter '${name}' in route ${id} cannot start or end with a slash`); 173 return param_value; 174 }), 175 ) 176 .filter(Boolean) 177 .join('/') + 178 (has_id_trailing_slash ? '/' : '') 179 ); 180} 181 182function escape(str: string): string { 183 return str 184 .normalize() 185 .replace(/[[\]]/g, '\\$&') 186 .replace(/%/g, '%25') 187 .replace(/\//g, '%2[Ff]') 188 .replace(/\?/g, '%3[Ff]') 189 .replace(/#/g, '%23') 190 .replace(/[.*+?^${}()|\\]/g, '\\$&'); 191} 192 193/** 194 * Analyzes a Vue file to determine its type and properties 195 */ 196function analyze( 197 project_relative: string, 198 file: string, 199 component_extensions: string[], 200): { 201 kind: 'component'; 202 is_page: boolean; 203 is_layout: boolean; 204 uses_layout?: string; 205} { 206 const component_extension = component_extensions.find((ext) => file.endsWith(ext)); 207 if (component_extension) { 208 const name = file.slice(0, -component_extension.length); 209 const pattern = /^\+(?:(page(?:@(.*))?)|(layout(?:@(.*))?))$/; 210 const match = pattern.exec(name); 211 if (!match) { 212 throw new Error(`Files prefixed with + are reserved (saw ${project_relative})`); 213 } 214 215 return { 216 kind: 'component', 217 is_page: !!match[1], 218 is_layout: !!match[3], 219 uses_layout: match[2] ?? match[4], 220 }; 221 } 222 223 throw new Error(`Files and directories prefixed with + are reserved (saw ${project_relative})`); 224} 225 226/** 227 * Creates routes and nodes from filesystem structure 228 */ 229export function create_routes_and_nodes(config: RoutingConfig): { 230 routes: RouteData[]; 231 nodes: PageNode[]; 232} { 233 const routes: RouteData[] = []; 234 const nodes: PageNode[] = []; 235 const cwd = config.cwd || process.cwd(); 236 const routes_base = path.relative(cwd, config.routes_dir); 237 238 const walk = (depth: number, id: string, segment: string, parent: RouteData | null) => { 239 const unescaped = id.replace(/\[([ux])\+([^\]]+)\]/gi, (match, type, code) => { 240 if (match !== match.toLowerCase()) { 241 throw new Error(`Character escape sequence in ${id} must be lowercase`); 242 } 243 244 if (!/[0-9a-f]+/.test(code)) { 245 throw new Error(`Invalid character escape sequence in ${id}`); 246 } 247 248 if (type === 'x') { 249 if (code.length !== 2) { 250 throw new Error(`Hexadecimal escape sequence in ${id} must be two characters`); 251 } 252 return String.fromCharCode(parseInt(code, 16)); 253 } else { 254 if (code.length < 4 || code.length > 6) { 255 throw new Error(`Unicode escape sequence in ${id} must be between four and six characters`); 256 } 257 return String.fromCharCode(parseInt(code, 16)); 258 } 259 }); 260 261 // Validation checks 262 if (/\]\[/.test(unescaped)) { 263 throw new Error(`Invalid route ${id} — parameters must be separated`); 264 } 265 266 if (count_occurrences('[', id) !== count_occurrences(']', id)) { 267 throw new Error(`Invalid route ${id} — brackets are unbalanced`); 268 } 269 270 if (/#/.test(segment)) { 271 throw new Error(`Route ${id} should be renamed to ${id.replace(/#/g, '[x+23]')}`); 272 } 273 274 if (/\[\.\.\.\w+\]\/\[\[/.test(id)) { 275 throw new Error( 276 `Invalid route ${id} — an [[optional]] route segment cannot follow a [...rest] route segment`, 277 ); 278 } 279 280 if (/\[\[\.\.\./.test(id)) { 281 throw new Error( 282 `Invalid route ${id} — a rest route segment is always optional, remove the outer square brackets`, 283 ); 284 } 285 286 const { pattern, params } = parse_route_id(id); 287 288 const route: RouteData = { 289 id, 290 parent, 291 segment, 292 pattern, 293 params, 294 layout: null, 295 leaf: null, 296 page: null, 297 }; 298 299 routes.push(route); 300 Object.defineProperty(route, 'parent', { enumerable: false }); 301 302 const dir = path.join(cwd, routes_base, id); 303 304 if (!fs.existsSync(dir)) return; 305 306 const files = fs.readdirSync(dir).map((name) => ({ 307 is_dir: fs.statSync(path.join(dir, name)).isDirectory(), 308 name, 309 })); 310 311 const valid_extensions = [...config.extensions]; 312 313 // process files first 314 for (const file of files) { 315 if (file.is_dir) continue; 316 317 const ext = valid_extensions.find((ext) => file.name.endsWith(ext)); 318 if (!ext) continue; 319 320 if (!file.name.startsWith('+')) { 321 const name = file.name.slice(0, -ext.length); 322 // check if it is a valid route filename but missing the + prefix 323 const typo = /^(?:(page(?:@(.*))?)|(layout(?:@(.*))?))$/.test(name); 324 if (typo) { 325 console.warn(`Missing route file prefix. Did you mean +${file.name}?`); 326 } 327 continue; 328 } 329 330 const project_relative = path.relative(cwd, path.join(dir, file.name)).replace(/\\/g, '/'); 331 const item = analyze(project_relative, file.name, config.extensions); 332 333 const duplicate_files_error = (type: string, existing_file: string): Error => { 334 return new Error( 335 `Multiple ${type} files found in ${routes_base}${route.id} : ${path.basename( 336 existing_file, 337 )} and ${file.name}`, 338 ); 339 }; 340 341 if (item.is_layout) { 342 if (!route.layout) { 343 route.layout = { depth, child_pages: [] }; 344 } else if (route.layout.component) { 345 throw duplicate_files_error('layout component', route.layout.component); 346 } 347 348 route.layout.component = project_relative; 349 if (item.uses_layout !== undefined) route.layout.parent_id = item.uses_layout; 350 } else if (item.is_page) { 351 if (!route.leaf) { 352 route.leaf = { depth }; 353 } else if (route.leaf.component) { 354 throw duplicate_files_error('page component', route.leaf.component); 355 } 356 357 route.leaf.component = project_relative; 358 if (item.uses_layout !== undefined) route.leaf.parent_id = item.uses_layout; 359 } 360 } 361 362 // Then handle children 363 for (const file of files) { 364 if (file.is_dir) { 365 walk(depth + 1, path.posix.join(id, file.name), file.name, route); 366 } 367 } 368 }; 369 370 walk(0, '/', '', null); 371 372 prevent_conflicts(routes); 373 374 // we do layouts first as they are more likely to be reused, 375 // and smaller indexes take fewer bytes 376 for (const route of routes) { 377 if (route.layout) { 378 nodes.push(route.layout); 379 } 380 } 381 382 for (const route of routes) { 383 if (route.leaf) nodes.push(route.leaf); 384 } 385 386 const indexes = new Map(nodes.map((node, i) => [node, i])); 387 388 for (const route of routes) { 389 if (!route.leaf) continue; 390 391 route.page = { 392 layouts: [], 393 leaf: indexes.get(route.leaf)!, 394 }; 395 396 let current_route: RouteData | null = route; 397 let current_node = route.leaf; 398 let parent_id = route.leaf.parent_id; 399 400 while (current_route) { 401 if (parent_id === undefined || current_route.segment === parent_id) { 402 if (current_route.layout) { 403 route.page.layouts.unshift(indexes.get(current_route.layout)); 404 } 405 406 if (current_route.layout) { 407 if (!current_route.layout.child_pages) { 408 current_route.layout.child_pages = []; 409 } 410 current_route.layout.child_pages.push(route.leaf); 411 current_node.parent = current_node = current_route.layout; 412 parent_id = current_node.parent_id; 413 } else { 414 parent_id = undefined; 415 } 416 } 417 418 current_route = current_route.parent; 419 } 420 421 if (parent_id !== undefined) { 422 throw new Error(`${current_node.component} references missing segment "${parent_id}"`); 423 } 424 } 425 426 return { 427 nodes, 428 routes: sort_routes(routes), 429 }; 430} 431 432function prevent_conflicts(routes: RouteData[]): void { 433 const lookup = new Map<string, string>(); 434 435 for (const route of routes) { 436 if (!route.leaf) continue; 437 438 const normalized = normalize_route_id(route.id); 439 440 // find all permutations created by optional parameters 441 const split = normalized.split(/<\?(.+?)>/g); 442 443 let permutations = [split[0] as string]; 444 445 // turn `x/[[optional]]/y` into `x/y` and `x/[required]/y` 446 for (let i = 1; i < split.length; i += 2) { 447 const matcher = split[i]; 448 const next = split[i + 1]; 449 450 permutations = permutations.reduce((a, b) => { 451 a.push(b + next); 452 if (!(matcher === '*' && b.endsWith('//'))) a.push(b + `<${matcher}>${next}`); 453 return a; 454 }, [] as string[]); 455 } 456 457 for (const permutation of permutations) { 458 // remove leading/trailing/duplicated slashes caused by prior 459 // manipulation of optional parameters and (groups) 460 const key = permutation 461 .replace(/\/{2,}/, '/') 462 .replace(/^\//, '') 463 .replace(/\/$/, ''); 464 465 if (lookup.has(key)) { 466 throw new Error(`The "${lookup.get(key)}" and "${route.id}" routes conflict with each other`); 467 } 468 469 lookup.set(key, route.id); 470 } 471 } 472} 473 474function normalize_route_id(id: string): string { 475 return ( 476 id 477 // remove groups 478 .replace(/(?<=^|\/)\(.+?\)(?=$|\/)/g, '') 479 480 .replace(/\[[ux]\+([0-9a-f]+)\]/g, (_, x) => String.fromCharCode(parseInt(x, 16)).replace(/\//g, '%2f')) 481 482 // replace `[param]` with `<*>`, `[param=x]` with `<x>`, and `[[param]]` with `<?*>` 483 .replace( 484 /\[(?:(\[)|(\.\.\.))?.+?(=.+?)?\]\]?/g, 485 (_, optional, rest, matcher) => `<${optional ? '?' : ''}${rest ?? ''}${matcher ?? '*'}>`, 486 ) 487 ); 488} 489 490function count_occurrences(needle: string, haystack: string): number { 491 let count = 0; 492 for (let i = 0; i < haystack.length; i += 1) { 493 if (haystack[i] === needle) count += 1; 494 } 495 return count; 496} 497 498function sort_routes(routes: RouteData[]): RouteData[] { 499 interface Part { 500 type: 'static' | 'required' | 'optional' | 'rest'; 501 content: string; 502 matched: boolean; 503 } 504 505 const EMPTY: Part = { type: 'static', content: '', matched: false }; 506 const segment_cache = new Map<string, Part[]>(); 507 508 function get_parts(segment: string): Part[] { 509 if (!segment_cache.has(segment)) { 510 segment_cache.set(segment, split(segment)); 511 } 512 return segment_cache.get(segment)!; 513 } 514 515 function split(id: string): Part[] { 516 const parts: Part[] = []; 517 let i = 0; 518 519 while (i <= id.length) { 520 const start = id.indexOf('[', i); 521 if (start === -1) { 522 parts.push({ type: 'static', content: id.slice(i), matched: false }); 523 break; 524 } 525 526 parts.push({ type: 'static', content: id.slice(i, start), matched: false }); 527 528 const type = id[start + 1] === '[' ? 'optional' : id[start + 1] === '.' ? 'rest' : 'required'; 529 const delimiter = type === 'optional' ? ']]' : ']'; 530 const end = id.indexOf(delimiter, start); 531 532 if (end === -1) { 533 throw new Error(`Invalid route ID ${id}`); 534 } 535 536 const content = id.slice(start, (i = end + delimiter.length)); 537 538 parts.push({ 539 type, 540 content, 541 matched: content.includes('='), 542 }); 543 } 544 545 return parts; 546 } 547 548 function split_route_id(id: string): string[] { 549 return get_route_segments(id.replace(/\[\[[^\]]+\]\](?!(?:\/\([^/]+\))*$)/g, '')).filter(Boolean); 550 } 551 552 function sort_static(a: string, b: string): number { 553 if (a === b) return 0; 554 555 for (let i = 0; true; i += 1) { 556 const char_a = a[i]; 557 const char_b = b[i]; 558 559 if (char_a !== char_b) { 560 if (char_a === undefined) return +1; 561 if (char_b === undefined) return -1; 562 return char_a < char_b ? -1 : +1; 563 } 564 } 565 } 566 567 return routes.sort((route_a, route_b) => { 568 const segments_a = split_route_id(route_a.id).map(get_parts); 569 const segments_b = split_route_id(route_b.id).map(get_parts); 570 571 for (let i = 0; i < Math.max(segments_a.length, segments_b.length); i += 1) { 572 const segment_a = segments_a[i] ?? [EMPTY]; 573 const segment_b = segments_b[i] ?? [EMPTY]; 574 575 for (let j = 0; j < Math.max(segment_a.length, segment_b.length); j += 1) { 576 const a = segment_a[j]; 577 const b = segment_b[j]; 578 579 const dynamic = j % 2 === 1; 580 581 if (dynamic) { 582 if (!a) return -1; 583 if (!b) return +1; 584 585 const next_a = segment_a[j + 1]?.content || segments_a[i + 1]?.[0]?.content; 586 const next_b = segment_b[j + 1]?.content || segments_b[i + 1]?.[0]?.content; 587 588 if (a.type === 'rest' && b.type === 'rest') { 589 if (next_a && next_b) continue; 590 if (next_a) return -1; 591 if (next_b) return +1; 592 } 593 594 if (a.type === 'rest') { 595 return next_a && !next_b ? -1 : +1; 596 } 597 598 if (b.type === 'rest') { 599 return next_b && !next_a ? +1 : -1; 600 } 601 602 if (a.matched !== b.matched) { 603 return a.matched ? -1 : +1; 604 } 605 606 if (a.type !== b.type) { 607 if (a.type === 'required') return -1; 608 if (b.type === 'required') return +1; 609 } 610 } else if (a.content !== b.content) { 611 if (a === EMPTY) return -1; 612 if (b === EMPTY) return +1; 613 return sort_static(a.content, b.content); 614 } 615 } 616 } 617 618 return route_a.id < route_b.id ? +1 : -1; 619 }); 620}