@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.
@@ -2,6 +2,7 @@ import path from 'node:path'
2
2
  import * as fsp from 'node:fs/promises'
3
3
  import {
4
4
  determineInitialRoutePath,
5
+ hasEscapedLeadingUnderscore,
5
6
  removeExt,
6
7
  replaceBackslash,
7
8
  routePathToVariable,
@@ -34,7 +35,6 @@ export async function getRouteNodes(
34
35
  | 'disableLogging'
35
36
  | 'routeToken'
36
37
  | 'indexToken'
37
- | 'experimental'
38
38
  >,
39
39
  root: string,
40
40
  ): Promise<GetRouteNodesResult> {
@@ -135,8 +135,7 @@ export async function getRouteNodes(
135
135
  const {
136
136
  routePath: initialRoutePath,
137
137
  originalRoutePath: initialOriginalRoutePath,
138
- isExperimentalNonNestedRoute,
139
- } = determineInitialRoutePath(filePathNoExt, config)
138
+ } = determineInitialRoutePath(filePathNoExt)
140
139
 
141
140
  let routePath = initialRoutePath
142
141
  let originalRoutePath = initialOriginalRoutePath
@@ -155,7 +154,7 @@ export async function getRouteNodes(
155
154
  throw new Error(errorMessage)
156
155
  }
157
156
 
158
- const meta = getRouteMeta(routePath, config)
157
+ const meta = getRouteMeta(routePath, originalRoutePath, config)
159
158
  const variableName = meta.variableName
160
159
  let routeType: FsRouteType = meta.fsRouteType
161
160
 
@@ -166,7 +165,14 @@ export async function getRouteNodes(
166
165
 
167
166
  // this check needs to happen after the lazy route has been cleaned up
168
167
  // since the routePath is used to determine if a route is pathless
169
- if (isValidPathlessLayoutRoute(routePath, routeType, config)) {
168
+ if (
169
+ isValidPathlessLayoutRoute(
170
+ routePath,
171
+ originalRoutePath,
172
+ routeType,
173
+ config,
174
+ )
175
+ ) {
170
176
  routeType = 'pathless_layout'
171
177
  }
172
178
 
@@ -191,44 +197,70 @@ export async function getRouteNodes(
191
197
  })
192
198
  }
193
199
 
194
- routePath = routePath.replace(
195
- new RegExp(
196
- `/(component|errorComponent|notFoundComponent|pendingComponent|loader|${config.routeToken}|lazy)$`,
197
- ),
198
- '',
199
- )
200
-
201
- originalRoutePath = originalRoutePath.replace(
202
- new RegExp(
203
- `/(component|errorComponent|notFoundComponent|pendingComponent|loader|${config.routeToken}|lazy)$`,
204
- ),
205
- '',
206
- )
200
+ // Get the last segment of originalRoutePath to check for escaping
201
+ const originalSegments = originalRoutePath.split('/').filter(Boolean)
202
+ const lastOriginalSegmentForSuffix =
203
+ originalSegments[originalSegments.length - 1] || ''
204
+
205
+ // List of special suffixes that can be escaped
206
+ const specialSuffixes = [
207
+ 'component',
208
+ 'errorComponent',
209
+ 'notFoundComponent',
210
+ 'pendingComponent',
211
+ 'loader',
212
+ config.routeToken,
213
+ 'lazy',
214
+ ]
215
+
216
+ // Only strip the suffix if it wasn't escaped (not wrapped in brackets)
217
+ const suffixToStrip = specialSuffixes.find((suffix) => {
218
+ const endsWithSuffix = routePath.endsWith(`/${suffix}`)
219
+ const isEscaped = lastOriginalSegmentForSuffix === `[${suffix}]`
220
+ return endsWithSuffix && !isEscaped
221
+ })
207
222
 
208
- if (routePath === config.indexToken) {
209
- routePath = '/'
223
+ if (suffixToStrip) {
224
+ routePath = routePath.replace(new RegExp(`/${suffixToStrip}$`), '')
225
+ originalRoutePath = originalRoutePath.replace(
226
+ new RegExp(`/${suffixToStrip}$`),
227
+ '',
228
+ )
210
229
  }
211
230
 
212
- if (originalRoutePath === config.indexToken) {
213
- originalRoutePath = '/'
231
+ // Check if the index token should be treated specially or as a literal path
232
+ // If it's escaped (wrapped in brackets in originalRoutePath), it should be literal
233
+ const lastOriginalSegment =
234
+ originalRoutePath.split('/').filter(Boolean).pop() || ''
235
+ const isIndexEscaped =
236
+ lastOriginalSegment === `[${config.indexToken}]`
237
+
238
+ if (!isIndexEscaped) {
239
+ if (routePath === config.indexToken) {
240
+ routePath = '/'
241
+ }
242
+
243
+ if (originalRoutePath === config.indexToken) {
244
+ originalRoutePath = '/'
245
+ }
246
+
247
+ routePath =
248
+ routePath.replace(new RegExp(`/${config.indexToken}$`), '/') ||
249
+ '/'
250
+
251
+ originalRoutePath =
252
+ originalRoutePath.replace(
253
+ new RegExp(`/${config.indexToken}$`),
254
+ '/',
255
+ ) || '/'
214
256
  }
215
257
 
216
- routePath =
217
- routePath.replace(new RegExp(`/${config.indexToken}$`), '/') || '/'
218
-
219
- originalRoutePath =
220
- originalRoutePath.replace(
221
- new RegExp(`/${config.indexToken}$`),
222
- '/',
223
- ) || '/'
224
-
225
258
  routeNodes.push({
226
259
  filePath,
227
260
  fullPath,
228
261
  routePath,
229
262
  variableName,
230
263
  _fsRouteType: routeType,
231
- _isExperimentalNonNestedRoute: isExperimentalNonNestedRoute,
232
264
  originalRoutePath,
233
265
  })
234
266
  }
@@ -269,12 +301,14 @@ export async function getRouteNodes(
269
301
  /**
270
302
  * Determines the metadata for a given route path based on the provided configuration.
271
303
  *
272
- * @param routePath - The determined initial routePath.
304
+ * @param routePath - The determined initial routePath (with brackets removed).
305
+ * @param originalRoutePath - The original route path (may contain brackets for escaped content).
273
306
  * @param config - The user configuration object.
274
307
  * @returns An object containing the type of the route and the variable name derived from the route path.
275
308
  */
276
309
  export function getRouteMeta(
277
310
  routePath: string,
311
+ originalRoutePath: string,
278
312
  config: Pick<Config, 'routeToken' | 'indexToken'>,
279
313
  ): {
280
314
  // `__root` is can be more easily determined by filtering down to routePath === /${rootPathId}
@@ -295,25 +329,50 @@ export function getRouteMeta(
295
329
  } {
296
330
  let fsRouteType: FsRouteType = 'static'
297
331
 
298
- if (routePath.endsWith(`/${config.routeToken}`)) {
332
+ // Get the last segment from the original path to check for escaping
333
+ const originalSegments = originalRoutePath.split('/').filter(Boolean)
334
+ const lastOriginalSegment =
335
+ originalSegments[originalSegments.length - 1] || ''
336
+
337
+ // Helper to check if a specific suffix is escaped
338
+ const isSuffixEscaped = (suffix: string): boolean => {
339
+ return lastOriginalSegment === `[${suffix}]`
340
+ }
341
+
342
+ if (
343
+ routePath.endsWith(`/${config.routeToken}`) &&
344
+ !isSuffixEscaped(config.routeToken)
345
+ ) {
299
346
  // layout routes, i.e `/foo/route.tsx` or `/foo/_layout/route.tsx`
300
347
  fsRouteType = 'layout'
301
- } else if (routePath.endsWith('/lazy')) {
348
+ } else if (routePath.endsWith('/lazy') && !isSuffixEscaped('lazy')) {
302
349
  // lazy routes, i.e. `/foo.lazy.tsx`
303
350
  fsRouteType = 'lazy'
304
- } else if (routePath.endsWith('/loader')) {
351
+ } else if (routePath.endsWith('/loader') && !isSuffixEscaped('loader')) {
305
352
  // loader routes, i.e. `/foo.loader.tsx`
306
353
  fsRouteType = 'loader'
307
- } else if (routePath.endsWith('/component')) {
354
+ } else if (
355
+ routePath.endsWith('/component') &&
356
+ !isSuffixEscaped('component')
357
+ ) {
308
358
  // component routes, i.e. `/foo.component.tsx`
309
359
  fsRouteType = 'component'
310
- } else if (routePath.endsWith('/pendingComponent')) {
360
+ } else if (
361
+ routePath.endsWith('/pendingComponent') &&
362
+ !isSuffixEscaped('pendingComponent')
363
+ ) {
311
364
  // pending component routes, i.e. `/foo.pendingComponent.tsx`
312
365
  fsRouteType = 'pendingComponent'
313
- } else if (routePath.endsWith('/errorComponent')) {
366
+ } else if (
367
+ routePath.endsWith('/errorComponent') &&
368
+ !isSuffixEscaped('errorComponent')
369
+ ) {
314
370
  // error component routes, i.e. `/foo.errorComponent.tsx`
315
371
  fsRouteType = 'errorComponent'
316
- } else if (routePath.endsWith('/notFoundComponent')) {
372
+ } else if (
373
+ routePath.endsWith('/notFoundComponent') &&
374
+ !isSuffixEscaped('notFoundComponent')
375
+ ) {
317
376
  // not found component routes, i.e. `/foo.notFoundComponent.tsx`
318
377
  fsRouteType = 'notFoundComponent'
319
378
  }
@@ -326,11 +385,14 @@ export function getRouteMeta(
326
385
  /**
327
386
  * Used to validate if a route is a pathless layout route
328
387
  * @param normalizedRoutePath Normalized route path, i.e `/foo/_layout/route.tsx` and `/foo._layout.route.tsx` to `/foo/_layout/route`
388
+ * @param originalRoutePath Original route path with brackets for escaped content
389
+ * @param routeType The route type determined from file extension
329
390
  * @param config The `router-generator` configuration object
330
391
  * @returns Boolean indicating if the route is a pathless layout route
331
392
  */
332
393
  function isValidPathlessLayoutRoute(
333
394
  normalizedRoutePath: string,
395
+ originalRoutePath: string,
334
396
  routeType: FsRouteType,
335
397
  config: Pick<Config, 'routeToken' | 'indexToken'>,
336
398
  ): boolean {
@@ -339,13 +401,18 @@ function isValidPathlessLayoutRoute(
339
401
  }
340
402
 
341
403
  const segments = normalizedRoutePath.split('/').filter(Boolean)
404
+ const originalSegments = originalRoutePath.split('/').filter(Boolean)
342
405
 
343
406
  if (segments.length === 0) {
344
407
  return false
345
408
  }
346
409
 
347
410
  const lastRouteSegment = segments[segments.length - 1]!
411
+ const lastOriginalSegment =
412
+ originalSegments[originalSegments.length - 1] || ''
348
413
  const secondToLastRouteSegment = segments[segments.length - 2]
414
+ const secondToLastOriginalSegment =
415
+ originalSegments[originalSegments.length - 2]
349
416
 
350
417
  // If segment === __root, then exit as false
351
418
  if (lastRouteSegment === rootPathId) {
@@ -355,14 +422,25 @@ function isValidPathlessLayoutRoute(
355
422
  // If segment === config.routeToken and secondToLastSegment is a string that starts with _, then exit as true
356
423
  // Since the route is actually a configuration route for a layout/pathless route
357
424
  // i.e. /foo/_layout/route.tsx === /foo/_layout.tsx
425
+ // But if the underscore is escaped, it's not a pathless layout
358
426
  if (
359
427
  lastRouteSegment === config.routeToken &&
360
- typeof secondToLastRouteSegment === 'string'
428
+ typeof secondToLastRouteSegment === 'string' &&
429
+ typeof secondToLastOriginalSegment === 'string'
361
430
  ) {
431
+ // Check if the underscore is escaped
432
+ if (hasEscapedLeadingUnderscore(secondToLastOriginalSegment)) {
433
+ return false
434
+ }
362
435
  return secondToLastRouteSegment.startsWith('_')
363
436
  }
364
437
 
365
- // Segment starts with _
438
+ // Segment starts with _ but check if it's escaped
439
+ // If the original segment has [_] at the start, the underscore is escaped and it's not a pathless layout
440
+ if (hasEscapedLeadingUnderscore(lastOriginalSegment)) {
441
+ return false
442
+ }
443
+
366
444
  return (
367
445
  lastRouteSegment !== config.indexToken &&
368
446
  lastRouteSegment !== config.routeToken &&
package/src/generator.ts CHANGED
@@ -28,15 +28,15 @@ import {
28
28
  getResolvedRouteNodeVariableName,
29
29
  hasParentRoute,
30
30
  isRouteNodeValidForAugmentation,
31
+ isSegmentPathless,
31
32
  mergeImportDeclarations,
32
33
  multiSortBy,
33
34
  removeExt,
34
35
  removeGroups,
35
36
  removeLastSegmentFromPath,
36
- removeLayoutSegments,
37
- removeLeadingUnderscores,
37
+ removeLayoutSegmentsWithEscape,
38
38
  removeTrailingSlash,
39
- removeUnderscores,
39
+ removeUnderscoresWithEscape,
40
40
  replaceBackslash,
41
41
  trimPathLeft,
42
42
  } from './utils'
@@ -822,11 +822,8 @@ export class Generator {
822
822
  let fileRoutesByFullPath = ''
823
823
 
824
824
  if (!config.disableTypes) {
825
- const routeNodesByFullPath = createRouteNodesByFullPath(
826
- acc.routeNodes,
827
- config,
828
- )
829
- const routeNodesByTo = createRouteNodesByTo(acc.routeNodes, config)
825
+ const routeNodesByFullPath = createRouteNodesByFullPath(acc.routeNodes)
826
+ const routeNodesByTo = createRouteNodesByTo(acc.routeNodes)
830
827
  const routeNodesById = createRouteNodesById(acc.routeNodes)
831
828
 
832
829
  fileRoutesByFullPath = [
@@ -1360,38 +1357,37 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
1360
1357
  prefixMap: RoutePrefixMap,
1361
1358
  config?: Config,
1362
1359
  ) {
1363
- const useExperimentalNonNestedRoutes =
1364
- config?.experimental?.nonNestedRoutes ?? false
1365
-
1366
- const parentRoute = hasParentRoute(
1367
- prefixMap,
1368
- node,
1369
- node.routePath,
1370
- node.originalRoutePath,
1371
- )
1360
+ const parentRoute = hasParentRoute(prefixMap, node, node.routePath)
1372
1361
 
1373
1362
  if (parentRoute) node.parent = parentRoute
1374
1363
 
1375
1364
  node.path = determineNodePath(node)
1376
1365
 
1377
1366
  const trimmedPath = trimPathLeft(node.path ?? '')
1367
+ const trimmedOriginalPath = trimPathLeft(
1368
+ node.originalRoutePath?.replace(
1369
+ node.parent?.originalRoutePath ?? '',
1370
+ '',
1371
+ ) ?? '',
1372
+ )
1378
1373
 
1379
1374
  const split = trimmedPath.split('/')
1375
+ const originalSplit = trimmedOriginalPath.split('/')
1380
1376
  const lastRouteSegment = split[split.length - 1] ?? trimmedPath
1377
+ const lastOriginalSegment =
1378
+ originalSplit[originalSplit.length - 1] ?? trimmedOriginalPath
1381
1379
 
1380
+ // A segment is non-path if it starts with underscore AND the underscore is not escaped
1382
1381
  node.isNonPath =
1383
- lastRouteSegment.startsWith('_') ||
1382
+ isSegmentPathless(lastRouteSegment, lastOriginalSegment) ||
1384
1383
  split.every((part) => this.routeGroupPatternRegex.test(part))
1385
1384
 
1386
- // with new nonNestedPaths feature we can be sure any remaining trailing underscores are escaped and should remain
1387
- // TODO with new major we can remove check and only remove leading underscores
1385
+ // Use escape-aware functions to compute cleanedPath
1388
1386
  node.cleanedPath = removeGroups(
1389
- (useExperimentalNonNestedRoutes
1390
- ? removeLeadingUnderscores(
1391
- removeLayoutSegments(node.path ?? ''),
1392
- config?.routeToken ?? '',
1393
- )
1394
- : removeUnderscores(removeLayoutSegments(node.path))) ?? '',
1387
+ removeUnderscoresWithEscape(
1388
+ removeLayoutSegmentsWithEscape(node.path, node.originalRoutePath),
1389
+ node.originalRoutePath,
1390
+ ),
1395
1391
  )
1396
1392
 
1397
1393
  if (
@@ -1450,6 +1446,8 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
1450
1446
  if (!node.isVirtual && isPathlessLayoutWithPath) {
1451
1447
  const immediateParentPath =
1452
1448
  removeLastSegmentFromPath(node.routePath) || '/'
1449
+ const immediateParentOriginalPath =
1450
+ removeLastSegmentFromPath(node.originalRoutePath) || '/'
1453
1451
  let searchPath = immediateParentPath
1454
1452
 
1455
1453
  // Find nearest real (non-virtual, non-index) parent
@@ -1461,8 +1459,19 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
1461
1459
  node.routePath?.replace(candidate.routePath ?? '', '') || '/'
1462
1460
  const pathRelativeToParent =
1463
1461
  immediateParentPath.replace(candidate.routePath ?? '', '') || '/'
1462
+ const originalPathRelativeToParent =
1463
+ immediateParentOriginalPath.replace(
1464
+ candidate.originalRoutePath ?? '',
1465
+ '',
1466
+ ) || '/'
1464
1467
  node.cleanedPath = removeGroups(
1465
- removeUnderscores(removeLayoutSegments(pathRelativeToParent)) ?? '',
1468
+ removeUnderscoresWithEscape(
1469
+ removeLayoutSegmentsWithEscape(
1470
+ pathRelativeToParent,
1471
+ originalPathRelativeToParent,
1472
+ ),
1473
+ originalPathRelativeToParent,
1474
+ ),
1466
1475
  )
1467
1476
  break
1468
1477
  }
package/src/types.ts CHANGED
@@ -13,7 +13,6 @@ export type RouteNode = {
13
13
  children?: Array<RouteNode>
14
14
  parent?: RouteNode
15
15
  createFileRouteProps?: Set<string>
16
- _isExperimentalNonNestedRoute?: boolean
17
16
  }
18
17
 
19
18
  export interface GetRouteNodesResult {