sveltekit-routing.ts
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}