@tanstack/router-generator 1.141.5 → 1.141.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/prefer-for-of */
1
2
  import * as fsp from 'node:fs/promises'
2
3
  import path from 'node:path'
3
4
  import * as prettier from 'prettier'
@@ -5,34 +6,170 @@ import { rootPathId } from './filesystem/physical/rootPathId'
5
6
  import type { Config } from './config'
6
7
  import type { ImportDeclaration, RouteNode } from './types'
7
8
 
9
+ /**
10
+ * Prefix map for O(1) parent route lookups.
11
+ * Maps each route path prefix to the route node that owns that prefix.
12
+ * Enables finding longest matching parent without linear search.
13
+ */
14
+ export class RoutePrefixMap {
15
+ private prefixToRoute: Map<string, RouteNode> = new Map()
16
+ private layoutRoutes: Array<RouteNode> = []
17
+ private nonNestedRoutes: Array<RouteNode> = []
18
+
19
+ constructor(routes: Array<RouteNode>) {
20
+ for (const route of routes) {
21
+ if (!route.routePath || route.routePath === `/${rootPathId}`) continue
22
+
23
+ // Index by exact path for direct lookups
24
+ this.prefixToRoute.set(route.routePath, route)
25
+
26
+ // Track layout routes separately for non-nested route handling
27
+ if (
28
+ route._fsRouteType === 'pathless_layout' ||
29
+ route._fsRouteType === 'layout' ||
30
+ route._fsRouteType === '__root'
31
+ ) {
32
+ this.layoutRoutes.push(route)
33
+ }
34
+
35
+ // Track non-nested routes separately
36
+ if (route._isExperimentalNonNestedRoute) {
37
+ this.nonNestedRoutes.push(route)
38
+ }
39
+ }
40
+
41
+ // Sort by path length descending for longest-match-first
42
+ this.layoutRoutes.sort(
43
+ (a, b) => (b.routePath?.length ?? 0) - (a.routePath?.length ?? 0),
44
+ )
45
+ this.nonNestedRoutes.sort(
46
+ (a, b) => (b.routePath?.length ?? 0) - (a.routePath?.length ?? 0),
47
+ )
48
+ }
49
+
50
+ /**
51
+ * Find the longest matching parent route for a given path.
52
+ * O(k) where k is the number of path segments, not O(n) routes.
53
+ */
54
+ findParent(routePath: string): RouteNode | null {
55
+ if (!routePath || routePath === '/') return null
56
+
57
+ // Walk up the path segments
58
+ let searchPath = routePath
59
+ while (searchPath.length > 0) {
60
+ const lastSlash = searchPath.lastIndexOf('/')
61
+ if (lastSlash <= 0) break
62
+
63
+ searchPath = searchPath.substring(0, lastSlash)
64
+ const parent = this.prefixToRoute.get(searchPath)
65
+ if (parent && parent.routePath !== routePath) {
66
+ return parent
67
+ }
68
+ }
69
+ return null
70
+ }
71
+
72
+ /**
73
+ * Find parent for non-nested routes (needs layout route matching).
74
+ */
75
+ findParentForNonNested(
76
+ routePath: string,
77
+ originalRoutePath: string | undefined,
78
+ nonNestedSegments: Array<string>,
79
+ ): RouteNode | null {
80
+ // First check for other non-nested routes that are prefixes
81
+ // Use pre-sorted array for longest-match-first
82
+ for (const route of this.nonNestedRoutes) {
83
+ if (
84
+ route.routePath !== routePath &&
85
+ originalRoutePath?.startsWith(`${route.originalRoutePath}/`)
86
+ ) {
87
+ return route
88
+ }
89
+ }
90
+
91
+ // Then check layout routes
92
+ for (const route of this.layoutRoutes) {
93
+ if (route.routePath === '/') continue
94
+
95
+ // Skip if this route's original path + underscore matches a non-nested segment
96
+ if (
97
+ nonNestedSegments.some((seg) => seg === `${route.originalRoutePath}_`)
98
+ ) {
99
+ continue
100
+ }
101
+
102
+ // Check if this layout route is a prefix of the path we're looking for
103
+ if (
104
+ routePath.startsWith(`${route.routePath}/`) &&
105
+ route.routePath !== routePath
106
+ ) {
107
+ return route
108
+ }
109
+ }
110
+
111
+ return null
112
+ }
113
+
114
+ /**
115
+ * Check if a route exists at the given path.
116
+ */
117
+ has(routePath: string): boolean {
118
+ return this.prefixToRoute.has(routePath)
119
+ }
120
+
121
+ /**
122
+ * Get a route by exact path.
123
+ */
124
+ get(routePath: string): RouteNode | undefined {
125
+ return this.prefixToRoute.get(routePath)
126
+ }
127
+ }
128
+
8
129
  export function multiSortBy<T>(
9
130
  arr: Array<T>,
10
131
  accessors: Array<(item: T) => any> = [(d) => d],
11
132
  ): Array<T> {
12
- return arr
13
- .map((d, i) => [d, i] as const)
14
- .sort(([a, ai], [b, bi]) => {
15
- for (const accessor of accessors) {
16
- const ao = accessor(a)
17
- const bo = accessor(b)
18
-
19
- if (typeof ao === 'undefined') {
20
- if (typeof bo === 'undefined') {
21
- continue
22
- }
23
- return 1
24
- }
133
+ const len = arr.length
134
+ // Pre-compute all accessor values to avoid repeated function calls during sort
135
+ const indexed: Array<{ item: T; index: number; keys: Array<any> }> =
136
+ new Array(len)
137
+ for (let i = 0; i < len; i++) {
138
+ const item = arr[i]!
139
+ const keys = new Array(accessors.length)
140
+ for (let j = 0; j < accessors.length; j++) {
141
+ keys[j] = accessors[j]!(item)
142
+ }
143
+ indexed[i] = { item, index: i, keys }
144
+ }
145
+
146
+ indexed.sort((a, b) => {
147
+ for (let j = 0; j < accessors.length; j++) {
148
+ const ao = a.keys[j]
149
+ const bo = b.keys[j]
25
150
 
26
- if (ao === bo) {
151
+ if (typeof ao === 'undefined') {
152
+ if (typeof bo === 'undefined') {
27
153
  continue
28
154
  }
155
+ return 1
156
+ }
29
157
 
30
- return ao > bo ? 1 : -1
158
+ if (ao === bo) {
159
+ continue
31
160
  }
32
161
 
33
- return ai - bi
34
- })
35
- .map(([d]) => d)
162
+ return ao > bo ? 1 : -1
163
+ }
164
+
165
+ return a.index - b.index
166
+ })
167
+
168
+ const result: Array<T> = new Array(len)
169
+ for (let i = 0; i < len; i++) {
170
+ result[i] = indexed[i]!.item
171
+ }
172
+ return result
36
173
  }
37
174
 
38
175
  export function cleanPath(path: string) {
@@ -143,54 +280,74 @@ export function determineInitialRoutePath(
143
280
  }
144
281
  }
145
282
 
283
+ const backslashRegex = /\\/g
284
+
146
285
  export function replaceBackslash(s: string) {
147
- return s.replaceAll(/\\/gi, '/')
286
+ return s.replace(backslashRegex, '/')
148
287
  }
149
288
 
150
- export function routePathToVariable(routePath: string): string {
151
- const toVariableSafeChar = (char: string): string => {
152
- if (/[a-zA-Z0-9_]/.test(char)) {
153
- return char // Keep alphanumeric characters and underscores as is
154
- }
289
+ const alphanumericRegex = /[a-zA-Z0-9_]/
290
+ const splatSlashRegex = /\/\$\//g
291
+ const trailingSplatRegex = /\$$/g
292
+ const bracketSplatRegex = /\$\{\$\}/g
293
+ const dollarSignRegex = /\$/g
294
+ const splitPathRegex = /[/-]/g
295
+ const leadingDigitRegex = /^(\d)/g
296
+
297
+ const toVariableSafeChar = (char: string): string => {
298
+ if (alphanumericRegex.test(char)) {
299
+ return char // Keep alphanumeric characters and underscores as is
300
+ }
155
301
 
156
- // Replace special characters with meaningful text equivalents
157
- switch (char) {
158
- case '.':
159
- return 'Dot'
160
- case '-':
161
- return 'Dash'
162
- case '@':
163
- return 'At'
164
- case '(':
165
- return '' // Removed since route groups use parentheses
166
- case ')':
167
- return '' // Removed since route groups use parentheses
168
- case ' ':
169
- return '' // Remove spaces
170
- default:
171
- return `Char${char.charCodeAt(0)}` // For any other characters
302
+ // Replace special characters with meaningful text equivalents
303
+ switch (char) {
304
+ case '.':
305
+ return 'Dot'
306
+ case '-':
307
+ return 'Dash'
308
+ case '@':
309
+ return 'At'
310
+ case '(':
311
+ return '' // Removed since route groups use parentheses
312
+ case ')':
313
+ return '' // Removed since route groups use parentheses
314
+ case ' ':
315
+ return '' // Remove spaces
316
+ default:
317
+ return `Char${char.charCodeAt(0)}` // For any other characters
318
+ }
319
+ }
320
+
321
+ export function routePathToVariable(routePath: string): string {
322
+ const cleaned = removeUnderscores(routePath)
323
+ if (!cleaned) return ''
324
+
325
+ const parts = cleaned
326
+ .replace(splatSlashRegex, '/splat/')
327
+ .replace(trailingSplatRegex, 'splat')
328
+ .replace(bracketSplatRegex, 'splat')
329
+ .replace(dollarSignRegex, '')
330
+ .split(splitPathRegex)
331
+
332
+ let result = ''
333
+ for (let i = 0; i < parts.length; i++) {
334
+ const part = parts[i]!
335
+ const segment = i > 0 ? capitalize(part) : part
336
+ for (let j = 0; j < segment.length; j++) {
337
+ result += toVariableSafeChar(segment[j]!)
172
338
  }
173
339
  }
174
340
 
175
- return (
176
- removeUnderscores(routePath)
177
- ?.replace(/\/\$\//g, '/splat/')
178
- .replace(/\$$/g, 'splat')
179
- .replace(/\$\{\$\}/g, 'splat')
180
- .replace(/\$/g, '')
181
- .split(/[/-]/g)
182
- .map((d, i) => (i > 0 ? capitalize(d) : d))
183
- .join('')
184
- .split('')
185
- .map(toVariableSafeChar)
186
- .join('')
187
- // .replace(/([^a-zA-Z0-9]|[.])/gm, '')
188
- .replace(/^(\d)/g, 'R$1') ?? ''
189
- )
341
+ return result.replace(leadingDigitRegex, 'R$1')
190
342
  }
191
343
 
344
+ const underscoreStartEndRegex = /(^_|_$)/gi
345
+ const underscoreSlashRegex = /(\/_|_\/)/gi
346
+
192
347
  export function removeUnderscores(s?: string) {
193
- return s?.replaceAll(/(^_|_$)/gi, '').replaceAll(/(\/_|_\/)/gi, '/')
348
+ return s
349
+ ?.replace(underscoreStartEndRegex, '')
350
+ .replace(underscoreSlashRegex, '/')
194
351
  }
195
352
 
196
353
  function escapeRegExp(s: string): string {
@@ -361,86 +518,53 @@ export function removeLastSegmentFromPath(routePath: string = '/'): string {
361
518
  return segments.join('/')
362
519
  }
363
520
 
521
+ const nonNestedSegmentRegex = /_(?=\/|$)/g
522
+ const openBracketRegex = /\[/g
523
+ const closeBracketRegex = /\]/g
524
+
525
+ /**
526
+ * Extracts non-nested segments from a route path.
527
+ * Used for determining parent routes in non-nested route scenarios.
528
+ */
529
+ export function getNonNestedSegments(routePath: string): Array<string> {
530
+ nonNestedSegmentRegex.lastIndex = 0
531
+ const result: Array<string> = []
532
+ for (const match of routePath.matchAll(nonNestedSegmentRegex)) {
533
+ const beforeStr = routePath.substring(0, match.index)
534
+ openBracketRegex.lastIndex = 0
535
+ closeBracketRegex.lastIndex = 0
536
+ const openBrackets = beforeStr.match(openBracketRegex)?.length ?? 0
537
+ const closeBrackets = beforeStr.match(closeBracketRegex)?.length ?? 0
538
+ if (openBrackets === closeBrackets) {
539
+ result.push(routePath.substring(0, match.index + 1))
540
+ }
541
+ }
542
+ return result.reverse()
543
+ }
544
+
545
+ /**
546
+ * Find parent route using RoutePrefixMap for O(k) lookups instead of O(n).
547
+ */
364
548
  export function hasParentRoute(
365
- routes: Array<RouteNode>,
549
+ prefixMap: RoutePrefixMap,
366
550
  node: RouteNode,
367
551
  routePathToCheck: string | undefined,
368
552
  originalRoutePathToCheck: string | undefined,
369
553
  ): RouteNode | null {
370
- const getNonNestedSegments = (routePath: string) => {
371
- const regex = /_(?=\/|$)/g
372
-
373
- return [...routePath.matchAll(regex)]
374
- .filter((match) => {
375
- const beforeStr = routePath.substring(0, match.index)
376
- const openBrackets = (beforeStr.match(/\[/g) || []).length
377
- const closeBrackets = (beforeStr.match(/\]/g) || []).length
378
- return openBrackets === closeBrackets
379
- })
380
- .map((match) => routePath.substring(0, match.index + 1))
381
- .reverse()
382
- }
383
-
384
554
  if (!routePathToCheck || routePathToCheck === '/') {
385
555
  return null
386
556
  }
387
557
 
388
- const sortedNodes = multiSortBy(routes, [
389
- (d) => d.routePath!.length * -1,
390
- (d) => d.variableName,
391
- ]).filter((d) => d.routePath !== `/${rootPathId}`)
392
-
393
- const filteredNodes = node._isExperimentalNonNestedRoute
394
- ? []
395
- : [...sortedNodes]
396
-
397
558
  if (node._isExperimentalNonNestedRoute && originalRoutePathToCheck) {
398
559
  const nonNestedSegments = getNonNestedSegments(originalRoutePathToCheck)
399
-
400
- for (const route of sortedNodes) {
401
- if (route.routePath === '/') continue
402
-
403
- if (
404
- route._isExperimentalNonNestedRoute &&
405
- route.routePath !== routePathToCheck &&
406
- originalRoutePathToCheck.startsWith(`${route.originalRoutePath}/`)
407
- ) {
408
- return route
409
- }
410
-
411
- if (
412
- nonNestedSegments.find(
413
- (seg) => seg === `${route.originalRoutePath}_`,
414
- ) ||
415
- !(
416
- route._fsRouteType === 'pathless_layout' ||
417
- route._fsRouteType === 'layout' ||
418
- route._fsRouteType === '__root'
419
- )
420
- ) {
421
- continue
422
- }
423
-
424
- filteredNodes.push(route)
425
- }
426
- }
427
-
428
- for (const route of filteredNodes) {
429
- if (route.routePath === '/') continue
430
-
431
- if (
432
- routePathToCheck.startsWith(`${route.routePath}/`) &&
433
- route.routePath !== routePathToCheck
434
- ) {
435
- return route
436
- }
560
+ return prefixMap.findParentForNonNested(
561
+ routePathToCheck,
562
+ originalRoutePathToCheck,
563
+ nonNestedSegments,
564
+ )
437
565
  }
438
566
 
439
- const segments = routePathToCheck.split('/')
440
- segments.pop() // Remove the last segment
441
- const parentRoutePath = segments.join('/')
442
-
443
- return hasParentRoute(routes, node, parentRoutePath, originalRoutePathToCheck)
567
+ return prefixMap.findParent(routePathToCheck)
444
568
  }
445
569
 
446
570
  /**
@@ -682,28 +806,33 @@ export function lowerCaseFirstChar(value: string) {
682
806
  export function mergeImportDeclarations(
683
807
  imports: Array<ImportDeclaration>,
684
808
  ): Array<ImportDeclaration> {
685
- const merged: Record<string, ImportDeclaration> = {}
809
+ const merged = new Map<string, ImportDeclaration>()
686
810
 
687
811
  for (const imp of imports) {
688
- const key = `${imp.source}-${imp.importKind}`
689
- if (!merged[key]) {
690
- merged[key] = { ...imp, specifiers: [] }
812
+ const key = `${imp.source}-${imp.importKind ?? ''}`
813
+ let existing = merged.get(key)
814
+ if (!existing) {
815
+ existing = { ...imp, specifiers: [] }
816
+ merged.set(key, existing)
691
817
  }
818
+
819
+ const existingSpecs = existing.specifiers
692
820
  for (const specifier of imp.specifiers) {
693
- // check if the specifier already exists in the merged import
694
- if (
695
- !merged[key].specifiers.some(
696
- (existing) =>
697
- existing.imported === specifier.imported &&
698
- existing.local === specifier.local,
699
- )
700
- ) {
701
- merged[key].specifiers.push(specifier)
821
+ let found = false
822
+ for (let i = 0; i < existingSpecs.length; i++) {
823
+ const e = existingSpecs[i]!
824
+ if (e.imported === specifier.imported && e.local === specifier.local) {
825
+ found = true
826
+ break
827
+ }
828
+ }
829
+ if (!found) {
830
+ existingSpecs.push(specifier)
702
831
  }
703
832
  }
704
833
  }
705
834
 
706
- return Object.values(merged)
835
+ return [...merged.values()]
707
836
  }
708
837
 
709
838
  export const findParent = (node: RouteNode | undefined): string => {