@tanstack/router-generator 1.141.8 → 1.142.0

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
@@ -14,7 +14,6 @@ import type { ImportDeclaration, RouteNode } from './types'
14
14
  export class RoutePrefixMap {
15
15
  private prefixToRoute: Map<string, RouteNode> = new Map()
16
16
  private layoutRoutes: Array<RouteNode> = []
17
- private nonNestedRoutes: Array<RouteNode> = []
18
17
 
19
18
  constructor(routes: Array<RouteNode>) {
20
19
  for (const route of routes) {
@@ -23,7 +22,6 @@ export class RoutePrefixMap {
23
22
  // Index by exact path for direct lookups
24
23
  this.prefixToRoute.set(route.routePath, route)
25
24
 
26
- // Track layout routes separately for non-nested route handling
27
25
  if (
28
26
  route._fsRouteType === 'pathless_layout' ||
29
27
  route._fsRouteType === 'layout' ||
@@ -31,20 +29,12 @@ export class RoutePrefixMap {
31
29
  ) {
32
30
  this.layoutRoutes.push(route)
33
31
  }
34
-
35
- // Track non-nested routes separately
36
- if (route._isExperimentalNonNestedRoute) {
37
- this.nonNestedRoutes.push(route)
38
- }
39
32
  }
40
33
 
41
34
  // Sort by path length descending for longest-match-first
42
35
  this.layoutRoutes.sort(
43
36
  (a, b) => (b.routePath?.length ?? 0) - (a.routePath?.length ?? 0),
44
37
  )
45
- this.nonNestedRoutes.sort(
46
- (a, b) => (b.routePath?.length ?? 0) - (a.routePath?.length ?? 0),
47
- )
48
38
  }
49
39
 
50
40
  /**
@@ -69,48 +59,6 @@ export class RoutePrefixMap {
69
59
  return null
70
60
  }
71
61
 
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
62
  /**
115
63
  * Check if a route exists at the given path.
116
64
  */
@@ -192,55 +140,32 @@ export function removeTrailingSlash(s: string) {
192
140
  const BRACKET_CONTENT_RE = /\[(.*?)\]/g
193
141
  const SPLIT_REGEX = /(?<!\[)\.(?!\])/g
194
142
 
195
- export function determineInitialRoutePath(
196
- routePath: string,
197
- config?: Pick<Config, 'experimental' | 'routeToken' | 'indexToken'>,
198
- ) {
199
- const DISALLOWED_ESCAPE_CHARS = new Set([
200
- '/',
201
- '\\',
202
- '?',
203
- '#',
204
- ':',
205
- '*',
206
- '<',
207
- '>',
208
- '|',
209
- '!',
210
- '$',
211
- '%',
212
- ])
213
-
143
+ /**
144
+ * Characters that cannot be escaped in square brackets.
145
+ * These are characters that would cause issues in URLs or file systems.
146
+ */
147
+ const DISALLOWED_ESCAPE_CHARS = new Set([
148
+ '/',
149
+ '\\',
150
+ '?',
151
+ '#',
152
+ ':',
153
+ '*',
154
+ '<',
155
+ '>',
156
+ '|',
157
+ '!',
158
+ '$',
159
+ '%',
160
+ ])
161
+
162
+ export function determineInitialRoutePath(routePath: string) {
214
163
  const originalRoutePath =
215
164
  cleanPath(
216
165
  `/${(cleanPath(routePath) || '').split(SPLIT_REGEX).join('/')}`,
217
166
  ) || ''
218
167
 
219
- // check if the route path is a valid non-nested path,
220
- // TODO with new major rename to reflect not experimental anymore
221
- const isExperimentalNonNestedRoute = isValidNonNestedRoute(
222
- originalRoutePath,
223
- config,
224
- )
225
-
226
- let cleanedRoutePath = routePath
227
-
228
- // we already identified the path as non-nested and can now remove the trailing underscores
229
- // we need to do this now before we encounter any escaped trailing underscores
230
- // this way we can be sure any remaining trailing underscores should remain
231
- // TODO with new major we can remove check and always remove leading underscores
232
- if (config?.experimental?.nonNestedRoutes) {
233
- // we should leave trailing underscores if the route path is the root path
234
- if (originalRoutePath !== `/${rootPathId}`) {
235
- // remove trailing underscores on various path segments
236
- cleanedRoutePath = removeTrailingUnderscores(
237
- originalRoutePath,
238
- config.routeToken,
239
- )
240
- }
241
- }
242
-
243
- const parts = cleanedRoutePath.split(SPLIT_REGEX)
168
+ const parts = routePath.split(SPLIT_REGEX)
244
169
 
245
170
  // Escape any characters that in square brackets
246
171
  // we keep the original path untouched
@@ -275,11 +200,51 @@ export function determineInitialRoutePath(
275
200
 
276
201
  return {
277
202
  routePath: final,
278
- isExperimentalNonNestedRoute,
279
203
  originalRoutePath,
280
204
  }
281
205
  }
282
206
 
207
+ /**
208
+ * Checks if a segment is fully escaped (entirely wrapped in brackets with no nested brackets).
209
+ * E.g., "[index]" -> true, "[_layout]" -> true, "foo[.]bar" -> false, "index" -> false
210
+ */
211
+ function isFullyEscapedSegment(originalSegment: string): boolean {
212
+ return (
213
+ originalSegment.startsWith('[') &&
214
+ originalSegment.endsWith(']') &&
215
+ !originalSegment.slice(1, -1).includes('[') &&
216
+ !originalSegment.slice(1, -1).includes(']')
217
+ )
218
+ }
219
+
220
+ /**
221
+ * Checks if the leading underscore in a segment is escaped.
222
+ * Returns true if:
223
+ * - Segment starts with [_] pattern: "[_]layout" -> "_layout"
224
+ * - Segment is fully escaped and content starts with _: "[_1nd3x]" -> "_1nd3x"
225
+ */
226
+ export function hasEscapedLeadingUnderscore(originalSegment: string): boolean {
227
+ // Pattern: [_]something or [_something]
228
+ return (
229
+ originalSegment.startsWith('[_]') ||
230
+ (originalSegment.startsWith('[_') && isFullyEscapedSegment(originalSegment))
231
+ )
232
+ }
233
+
234
+ /**
235
+ * Checks if the trailing underscore in a segment is escaped.
236
+ * Returns true if:
237
+ * - Segment ends with [_] pattern: "blog[_]" -> "blog_"
238
+ * - Segment is fully escaped and content ends with _: "[_r0ut3_]" -> "_r0ut3_"
239
+ */
240
+ export function hasEscapedTrailingUnderscore(originalSegment: string): boolean {
241
+ // Pattern: something[_] or [something_]
242
+ return (
243
+ originalSegment.endsWith('[_]') ||
244
+ (originalSegment.endsWith('_]') && isFullyEscapedSegment(originalSegment))
245
+ )
246
+ }
247
+
283
248
  const backslashRegex = /\\/g
284
249
 
285
250
  export function replaceBackslash(s: string) {
@@ -350,6 +315,92 @@ export function removeUnderscores(s?: string) {
350
315
  .replace(underscoreSlashRegex, '/')
351
316
  }
352
317
 
318
+ /**
319
+ * Removes underscores from a path, but preserves underscores that were escaped
320
+ * in the original path (indicated by [_] syntax).
321
+ *
322
+ * @param routePath - The path with brackets removed
323
+ * @param originalPath - The original path that may contain [_] escape sequences
324
+ * @returns The path with non-escaped underscores removed
325
+ */
326
+ export function removeUnderscoresWithEscape(
327
+ routePath?: string,
328
+ originalPath?: string,
329
+ ): string {
330
+ if (!routePath) return ''
331
+ if (!originalPath) return removeUnderscores(routePath) ?? ''
332
+
333
+ const routeSegments = routePath.split('/')
334
+ const originalSegments = originalPath.split('/')
335
+
336
+ const newSegments = routeSegments.map((segment, i) => {
337
+ const originalSegment = originalSegments[i] || ''
338
+
339
+ // Check if leading underscore is escaped
340
+ const leadingEscaped = hasEscapedLeadingUnderscore(originalSegment)
341
+ // Check if trailing underscore is escaped
342
+ const trailingEscaped = hasEscapedTrailingUnderscore(originalSegment)
343
+
344
+ let result = segment
345
+
346
+ // Remove leading underscore only if not escaped
347
+ if (result.startsWith('_') && !leadingEscaped) {
348
+ result = result.slice(1)
349
+ }
350
+
351
+ // Remove trailing underscore only if not escaped
352
+ if (result.endsWith('_') && !trailingEscaped) {
353
+ result = result.slice(0, -1)
354
+ }
355
+
356
+ return result
357
+ })
358
+
359
+ return newSegments.join('/')
360
+ }
361
+
362
+ /**
363
+ * Removes layout segments (segments starting with underscore) from a path,
364
+ * but preserves segments where the underscore was escaped.
365
+ *
366
+ * @param routePath - The path with brackets removed
367
+ * @param originalPath - The original path that may contain [_] escape sequences
368
+ * @returns The path with non-escaped layout segments removed
369
+ */
370
+ export function removeLayoutSegmentsWithEscape(
371
+ routePath: string = '/',
372
+ originalPath?: string,
373
+ ): string {
374
+ if (!originalPath) return removeLayoutSegments(routePath)
375
+
376
+ const routeSegments = routePath.split('/')
377
+ const originalSegments = originalPath.split('/')
378
+
379
+ // Keep segments that are NOT pathless (i.e., don't start with unescaped underscore)
380
+ const newSegments = routeSegments.filter((segment, i) => {
381
+ const originalSegment = originalSegments[i] || ''
382
+ return !isSegmentPathless(segment, originalSegment)
383
+ })
384
+
385
+ return newSegments.join('/')
386
+ }
387
+
388
+ /**
389
+ * Checks if a segment should be treated as a pathless/layout segment.
390
+ * A segment is pathless if it starts with underscore and the underscore is not escaped.
391
+ *
392
+ * @param segment - The segment from routePath (brackets removed)
393
+ * @param originalSegment - The segment from originalRoutePath (may contain brackets)
394
+ * @returns true if the segment is pathless (has non-escaped leading underscore)
395
+ */
396
+ export function isSegmentPathless(
397
+ segment: string,
398
+ originalSegment: string,
399
+ ): boolean {
400
+ if (!segment.startsWith('_')) return false
401
+ return !hasEscapedLeadingUnderscore(originalSegment)
402
+ }
403
+
353
404
  function escapeRegExp(s: string): string {
354
405
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
355
406
  }
@@ -518,30 +569,6 @@ export function removeLastSegmentFromPath(routePath: string = '/'): string {
518
569
  return segments.join('/')
519
570
  }
520
571
 
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
572
  /**
546
573
  * Find parent route using RoutePrefixMap for O(k) lookups instead of O(n).
547
574
  */
@@ -549,21 +576,11 @@ export function hasParentRoute(
549
576
  prefixMap: RoutePrefixMap,
550
577
  node: RouteNode,
551
578
  routePathToCheck: string | undefined,
552
- originalRoutePathToCheck: string | undefined,
553
579
  ): RouteNode | null {
554
580
  if (!routePathToCheck || routePathToCheck === '/') {
555
581
  return null
556
582
  }
557
583
 
558
- if (node._isExperimentalNonNestedRoute && originalRoutePathToCheck) {
559
- const nonNestedSegments = getNonNestedSegments(originalRoutePathToCheck)
560
- return prefixMap.findParentForNonNested(
561
- routePathToCheck,
562
- originalRoutePathToCheck,
563
- nonNestedSegments,
564
- )
565
- }
566
-
567
584
  return prefixMap.findParent(routePathToCheck)
568
585
  }
569
586
 
@@ -606,16 +623,15 @@ export const inferPath = (routeNode: RouteNode): string => {
606
623
  /**
607
624
  * Infers the full path for use by TS
608
625
  */
609
- export const inferFullPath = (
610
- routeNode: RouteNode,
611
- config?: Pick<Config, 'experimental' | 'routeToken'>,
612
- ): string => {
613
- // with new nonNestedPaths feature we can be sure any remaining trailing underscores are escaped and should remain
614
- // TODO with new major we can remove check and only remove leading underscores
626
+ export const inferFullPath = (routeNode: RouteNode): string => {
615
627
  const fullPath = removeGroups(
616
- (config?.experimental?.nonNestedRoutes
617
- ? removeLayoutSegments(routeNode.routePath)
618
- : removeUnderscores(removeLayoutSegments(routeNode.routePath))) ?? '',
628
+ removeUnderscoresWithEscape(
629
+ removeLayoutSegmentsWithEscape(
630
+ routeNode.routePath,
631
+ routeNode.originalRoutePath,
632
+ ),
633
+ routeNode.originalRoutePath,
634
+ ),
619
635
  )
620
636
 
621
637
  return routeNode.cleanedPath === '/' ? fullPath : fullPath.replace(/\/$/, '')
@@ -626,13 +642,9 @@ export const inferFullPath = (
626
642
  */
627
643
  export const createRouteNodesByFullPath = (
628
644
  routeNodes: Array<RouteNode>,
629
- config?: Pick<Config, 'experimental' | 'routeToken'>,
630
645
  ): Map<string, RouteNode> => {
631
646
  return new Map(
632
- routeNodes.map((routeNode) => [
633
- inferFullPath(routeNode, config),
634
- routeNode,
635
- ]),
647
+ routeNodes.map((routeNode) => [inferFullPath(routeNode), routeNode]),
636
648
  )
637
649
  }
638
650
 
@@ -641,11 +653,10 @@ export const createRouteNodesByFullPath = (
641
653
  */
642
654
  export const createRouteNodesByTo = (
643
655
  routeNodes: Array<RouteNode>,
644
- config?: Pick<Config, 'experimental' | 'routeToken'>,
645
656
  ): Map<string, RouteNode> => {
646
657
  return new Map(
647
658
  dedupeBranchesAndIndexRoutes(routeNodes).map((routeNode) => [
648
- inferTo(routeNode, config),
659
+ inferTo(routeNode),
649
660
  routeNode,
650
661
  ]),
651
662
  )
@@ -668,11 +679,8 @@ export const createRouteNodesById = (
668
679
  /**
669
680
  * Infers to path
670
681
  */
671
- export const inferTo = (
672
- routeNode: RouteNode,
673
- config?: Pick<Config, 'experimental' | 'routeToken'>,
674
- ): string => {
675
- const fullPath = inferFullPath(routeNode, config)
682
+ export const inferTo = (routeNode: RouteNode): string => {
683
+ const fullPath = inferFullPath(routeNode)
676
684
 
677
685
  if (fullPath === '/') return fullPath
678
686
 
@@ -711,7 +719,7 @@ export function checkRouteFullPathUniqueness(
711
719
  config: Config,
712
720
  ) {
713
721
  const routes = _routes.map((d) => {
714
- const inferredFullPath = inferFullPath(d, config)
722
+ const inferredFullPath = inferFullPath(d)
715
723
  return { ...d, inferredFullPath }
716
724
  })
717
725
 
@@ -849,7 +857,7 @@ export function buildFileRoutesByPathInterface(opts: {
849
857
  routeNodes: Array<RouteNode>
850
858
  module: string
851
859
  interfaceName: string
852
- config?: Pick<Config, 'experimental' | 'routeToken'>
860
+ config?: Pick<Config, 'routeToken'>
853
861
  }): string {
854
862
  return `declare module '${opts.module}' {
855
863
  interface ${opts.interfaceName} {
@@ -863,7 +871,7 @@ export function buildFileRoutesByPathInterface(opts: {
863
871
  return `'${filePathId}': {
864
872
  id: '${filePathId}'
865
873
  path: '${inferPath(routeNode)}'
866
- fullPath: '${inferFullPath(routeNode, opts.config)}'
874
+ fullPath: '${inferFullPath(routeNode)}'
867
875
  preLoaderRoute: ${preloaderRoute}
868
876
  parentRoute: typeof ${parent}
869
877
  }`
@@ -916,51 +924,3 @@ export function getImportForRouteNode(
916
924
  ],
917
925
  } satisfies ImportDeclaration
918
926
  }
919
-
920
- /**
921
- * Used to validate if a route is a pathless layout route
922
- * @param normalizedRoutePath Normalized route path, i.e `/foo/_layout/route.tsx` and `/foo._layout.route.tsx` to `/foo/_layout/route`
923
- * @param config The `router-generator` configuration object
924
- * @returns Boolean indicating if the route is a pathless layout route
925
- */
926
- export function isValidNonNestedRoute(
927
- normalizedRoutePath: string,
928
- config?: Pick<Config, 'experimental' | 'routeToken' | 'indexToken'>,
929
- ): boolean {
930
- if (!config?.experimental?.nonNestedRoutes) {
931
- return false
932
- }
933
-
934
- const segments = normalizedRoutePath.split('/').filter(Boolean)
935
-
936
- if (segments.length === 0) {
937
- return false
938
- }
939
-
940
- const lastRouteSegment = segments[segments.length - 1]!
941
-
942
- // If segment === __root, then exit as false
943
- if (lastRouteSegment === rootPathId) {
944
- return false
945
- }
946
-
947
- if (
948
- lastRouteSegment !== config.indexToken &&
949
- lastRouteSegment !== config.routeToken &&
950
- lastRouteSegment.endsWith('_')
951
- ) {
952
- return true
953
- }
954
-
955
- for (const segment of segments.slice(0, -1).reverse()) {
956
- if (segment === config.routeToken) {
957
- return false
958
- }
959
-
960
- if (segment.endsWith('_')) {
961
- return true
962
- }
963
- }
964
-
965
- return false
966
- }