@tanstack/router-generator 1.19.0 → 1.19.3

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/generator.ts CHANGED
@@ -18,6 +18,9 @@ export type RouteNode = {
18
18
  path?: string
19
19
  isNonPath?: boolean
20
20
  isNonLayout?: boolean
21
+ isLayout?: boolean
22
+ isVirtualParentRequired?: boolean
23
+ isVirtualParentRoute?: boolean
21
24
  isRoute?: boolean
22
25
  isLoader?: boolean
23
26
  isComponent?: boolean
@@ -89,6 +92,9 @@ async function getRouteNodes(config: Config) {
89
92
  let isPendingComponent = routePath?.endsWith('/pendingComponent')
90
93
  let isLoader = routePath?.endsWith('/loader')
91
94
 
95
+ const segments = (routePath ?? '').split('/')
96
+ let isLayout = segments[segments.length - 1]?.startsWith('_') || false
97
+
92
98
  ;(
93
99
  [
94
100
  [isComponent, 'component'],
@@ -126,6 +132,7 @@ async function getRouteNodes(config: Config) {
126
132
  isPendingComponent,
127
133
  isLoader,
128
134
  isLazy,
135
+ isLayout,
129
136
  })
130
137
  }
131
138
  }),
@@ -205,72 +212,90 @@ export async function generator(config: Config) {
205
212
  let routeNodes: RouteNode[] = []
206
213
 
207
214
  const handleNode = async (node: RouteNode) => {
208
- const parentRoute = hasParentRoute(routeNodes, node.routePath)
215
+ let parentRoute = hasParentRoute(routeNodes, node, node.routePath)
216
+
217
+ // if the parent route is a virtual parent route, we need to find the real parent route
218
+ if (parentRoute?.isVirtualParentRoute && parentRoute.children?.length) {
219
+ // only if this sub-parent route returns a valid parent route, we use it, if not leave it as it
220
+ const possibleParentRoute = hasParentRoute(
221
+ parentRoute.children,
222
+ node,
223
+ node.routePath,
224
+ )
225
+ if (possibleParentRoute) {
226
+ parentRoute = possibleParentRoute
227
+ }
228
+ }
229
+
209
230
  if (parentRoute) node.parent = parentRoute
210
231
 
211
- node.path = node.parent
212
- ? node.routePath?.replace(node.parent.routePath!, '') || '/'
213
- : node.routePath
232
+ node.path = determineNodePath(node)
214
233
 
215
234
  const trimmedPath = trimPathLeft(node.path ?? '')
216
235
 
217
236
  const split = trimmedPath?.split('/') ?? []
218
237
  let first = split[0] ?? trimmedPath ?? ''
238
+ const lastRouteSegment = split[split.length - 1] ?? trimmedPath ?? ''
219
239
 
220
- node.isNonPath = first.startsWith('_')
240
+ node.isNonPath = lastRouteSegment.startsWith('_')
221
241
  node.isNonLayout = first.endsWith('_')
222
- node.cleanedPath = removeGroups(removeUnderscores(node.path) ?? '')
223
-
224
- // Ensure the boilerplate for the route exists
225
- const routeCode = fs.readFileSync(node.fullPath, 'utf-8')
226
242
 
227
- const escapedRoutePath = removeTrailingUnderscores(
228
- node.routePath?.replaceAll('$', '$$') ?? '',
243
+ node.cleanedPath = removeGroups(
244
+ removeUnderscores(removeLayoutSegments(node.path)) ?? '',
229
245
  )
230
246
 
231
- let replaced = routeCode
247
+ // Ensure the boilerplate for the route exists, which can be skipped for virtual parent routes
248
+ if (!node.isVirtualParentRoute) {
249
+ const routeCode = fs.readFileSync(node.fullPath, 'utf-8')
250
+
251
+ const escapedRoutePath = removeTrailingUnderscores(
252
+ node.routePath?.replaceAll('$', '$$') ?? '',
253
+ )
254
+
255
+ let replaced = routeCode
232
256
 
233
- if (!routeCode) {
234
- if (node.isLazy) {
235
- replaced = [
236
- `import { createLazyFileRoute } from '@tanstack/react-router'`,
237
- `export const Route = createLazyFileRoute('${escapedRoutePath}')({
257
+ if (!routeCode) {
258
+ if (node.isLazy) {
259
+ replaced = [
260
+ `import { createLazyFileRoute } from '@tanstack/react-router'`,
261
+ `export const Route = createLazyFileRoute('${escapedRoutePath}')({
238
262
  component: () => <div>Hello ${escapedRoutePath}!</div>
239
263
  })`,
240
- ].join('\n\n')
241
- } else if (
242
- node.isRoute ||
243
- (!node.isComponent &&
244
- !node.isErrorComponent &&
245
- !node.isPendingComponent &&
246
- !node.isLoader)
247
- ) {
248
- replaced = [
249
- `import { createFileRoute } from '@tanstack/react-router'`,
250
- `export const Route = createFileRoute('${escapedRoutePath}')({
264
+ ].join('\n\n')
265
+ } else if (
266
+ node.isRoute ||
267
+ (!node.isComponent &&
268
+ !node.isErrorComponent &&
269
+ !node.isPendingComponent &&
270
+ !node.isLoader)
271
+ ) {
272
+ replaced = [
273
+ `import { createFileRoute } from '@tanstack/react-router'`,
274
+ `export const Route = createFileRoute('${escapedRoutePath}')({
251
275
  component: () => <div>Hello ${escapedRoutePath}!</div>
252
276
  })`,
253
- ].join('\n\n')
277
+ ].join('\n\n')
278
+ }
279
+ } else {
280
+ replaced = routeCode
281
+ .replace(
282
+ /(FileRoute\(\s*['"])([^\s]*)(['"],?\s*\))/g,
283
+ (match, p1, p2, p3) => `${p1}${escapedRoutePath}${p3}`,
284
+ )
285
+ .replace(
286
+ /(createFileRoute\(\s*['"])([^\s]*)(['"],?\s*\))/g,
287
+ (match, p1, p2, p3) => `${p1}${escapedRoutePath}${p3}`,
288
+ )
289
+ .replace(
290
+ /(createLazyFileRoute\(\s*['"])([^\s]*)(['"],?\s*\))/g,
291
+ (match, p1, p2, p3) => `${p1}${escapedRoutePath}${p3}`,
292
+ )
254
293
  }
255
- } else {
256
- replaced = routeCode
257
- .replace(
258
- /(FileRoute\(\s*['"])([^\s]*)(['"],?\s*\))/g,
259
- (match, p1, p2, p3) => `${p1}${escapedRoutePath}${p3}`,
260
- )
261
- .replace(
262
- /(createFileRoute\(\s*['"])([^\s]*)(['"],?\s*\))/g,
263
- (match, p1, p2, p3) => `${p1}${escapedRoutePath}${p3}`,
264
- )
265
- .replace(
266
- /(createLazyFileRoute\(\s*['"])([^\s]*)(['"],?\s*\))/g,
267
- (match, p1, p2, p3) => `${p1}${escapedRoutePath}${p3}`,
268
- )
269
- }
270
294
 
271
- if (replaced !== routeCode) {
272
- logger.log(`🟡 Updating ${node.fullPath}`)
273
- await fsp.writeFile(node.fullPath, replaced)
295
+ if (replaced !== routeCode) {
296
+ logger.log(`🟡 Updating ${node.fullPath}`)
297
+ await fsp.writeFile(node.fullPath, replaced)
298
+ }
274
299
  }
275
300
 
276
301
  if (
@@ -312,9 +337,58 @@ export async function generator(config: Config) {
312
337
  return
313
338
  }
314
339
 
340
+ const cleanedPathIsEmpty = (node.cleanedPath || '').length === 0
341
+
342
+ node.isVirtualParentRequired = node.isLayout
343
+ ? !cleanedPathIsEmpty && !node.parent
344
+ : false
345
+
346
+ if (!node.isVirtual && node.isVirtualParentRequired) {
347
+ const parentRoutePath = removeLastSegmentFromPath(node.routePath) || '/'
348
+ const parentVariableName = routePathToVariable(parentRoutePath)
349
+
350
+ const anchorRoute = routeNodes.find(
351
+ (d) => d.routePath === parentRoutePath,
352
+ )
353
+
354
+ if (!anchorRoute) {
355
+ const parentNode = {
356
+ ...node,
357
+ path: removeLastSegmentFromPath(node.path) || '/',
358
+ filePath: removeLastSegmentFromPath(node.filePath) || '/',
359
+ fullPath: removeLastSegmentFromPath(node.fullPath) || '/',
360
+ routePath: parentRoutePath,
361
+ variableName: parentVariableName,
362
+ isVirtual: true,
363
+ isLayout: false,
364
+ isVirtualParentRoute: true,
365
+ isVirtualParentRequired: false,
366
+ }
367
+
368
+ parentNode.children = parentNode.children ?? []
369
+ parentNode.children.push(node)
370
+
371
+ node.parent = parentNode
372
+
373
+ if (node.isLayout) {
374
+ // since `node.path` is used as the `id` on the route definition, we need to update it
375
+ node.path = determineNodePath(node)
376
+ }
377
+
378
+ await handleNode(parentNode)
379
+ } else {
380
+ anchorRoute.children = anchorRoute.children ?? []
381
+ anchorRoute.children.push(node)
382
+
383
+ node.parent = anchorRoute
384
+ }
385
+ }
386
+
315
387
  if (node.parent) {
316
- node.parent.children = node.parent.children ?? []
317
- node.parent.children.push(node)
388
+ if (!node.isVirtualParentRequired) {
389
+ node.parent.children = node.parent.children ?? []
390
+ node.parent.children.push(node)
391
+ }
318
392
  } else {
319
393
  routeTree.push(node)
320
394
  }
@@ -333,6 +407,10 @@ export async function generator(config: Config) {
333
407
  return
334
408
  }
335
409
 
410
+ if (node.isLayout && !node.children?.length) {
411
+ return
412
+ }
413
+
336
414
  const route = `${node.variableName}Route`
337
415
 
338
416
  if (node.children?.length) {
@@ -370,7 +448,20 @@ export async function generator(config: Config) {
370
448
  .filter((d) => d[1])
371
449
  .map((d) => d[0])
372
450
 
373
- const virtualRouteNodes = sortedRouteNodes.filter((d) => d.isVirtual)
451
+ const virtualRouteNodes = sortedRouteNodes
452
+ .filter((d) => d.isVirtual)
453
+ .reduce((acc, route) => {
454
+ // ensuring we don't have any duplicated virtual routes or clashes with pre-existing routes
455
+ const existingPreNode = preRouteNodes.filter(
456
+ (d) => d.routePath === route.routePath,
457
+ )
458
+
459
+ if (existingPreNode.length === 0) {
460
+ acc.push(route)
461
+ }
462
+
463
+ return acc
464
+ }, [] as RouteNode[])
374
465
 
375
466
  const rootPathIdExtension =
376
467
  config.addExtensions && rootRouteNode
@@ -425,6 +516,14 @@ export async function generator(config: Config) {
425
516
  .join('\n'),
426
517
  '// Create/Update Routes',
427
518
  sortedRouteNodes
519
+ .reduce((acc, node) => {
520
+ // ensuring we update a unique route only once
521
+ if (acc.find((d) => d.routePath === node.routePath)) {
522
+ return acc
523
+ }
524
+ acc.push(node)
525
+ return acc
526
+ }, [] as RouteNode[])
428
527
  .map((node) => {
429
528
  const loaderNode = routePiecesByPath[node.routePath!]?.loader
430
529
  const componentNode = routePiecesByPath[node.routePath!]?.component
@@ -510,9 +609,11 @@ export async function generator(config: Config) {
510
609
  return `'${removeTrailingUnderscores(routeNode.routePath)}': {
511
610
  preLoaderRoute: typeof ${routeNode.variableName}Import
512
611
  parentRoute: typeof ${
513
- routeNode.parent?.variableName
514
- ? `${routeNode.parent?.variableName}Import`
515
- : 'rootRoute'
612
+ routeNode.isVirtualParentRequired
613
+ ? `${routeNode.parent?.variableName}Route`
614
+ : routeNode.parent?.variableName
615
+ ? `${routeNode.parent?.variableName}Import`
616
+ : 'rootRoute'
516
617
  }
517
618
  }`
518
619
  })
@@ -637,8 +738,49 @@ function removeGroups(s: string) {
637
738
  return s.replaceAll(routeGroupPatternRegex, '').replaceAll('//', '/')
638
739
  }
639
740
 
741
+ /**
742
+ * The `node.path` is used as the `id` in the route definition.
743
+ * This function checks if the given node has a parent and if so, it determines the correct path for the given node.
744
+ * @param node - The node to determine the path for.
745
+ * @returns The correct path for the given node.
746
+ */
747
+ function determineNodePath(node: RouteNode) {
748
+ return (node.path = node.parent
749
+ ? node.routePath?.replace(node.parent.routePath!, '') || '/'
750
+ : node.routePath)
751
+ }
752
+
753
+ /**
754
+ * Removes the last segment from a given path. Segments are considered to be separated by a '/'.
755
+ *
756
+ * @param {string} path - The path from which to remove the last segment. Defaults to '/'.
757
+ * @returns {string} The path with the last segment removed.
758
+ * @example
759
+ * removeLastSegmentFromPath('/workspace/_auth/foo') // '/workspace/_auth'
760
+ */
761
+ export function removeLastSegmentFromPath(path: string = '/'): string {
762
+ const segments = path.split('/')
763
+ segments.pop() // Remove the last segment
764
+ return segments.join('/')
765
+ }
766
+
767
+ /**
768
+ * Removes all segments from a given path that start with an underscore ('_').
769
+ *
770
+ * @param {string} path - The path from which to remove segments. Defaults to '/'.
771
+ * @returns {string} The path with all underscore-prefixed segments removed.
772
+ * @example
773
+ * removeLayoutSegments('/workspace/_auth/foo') // '/workspace/foo'
774
+ */
775
+ function removeLayoutSegments(path: string = '/'): string {
776
+ const segments = path.split('/')
777
+ const newSegments = segments.filter((segment) => !segment.startsWith('_'))
778
+ return newSegments.join('/')
779
+ }
780
+
640
781
  export function hasParentRoute(
641
782
  routes: RouteNode[],
783
+ node: RouteNode,
642
784
  routePathToCheck: string | undefined,
643
785
  ): RouteNode | null {
644
786
  if (!routePathToCheck || routePathToCheck === '/') {
@@ -650,19 +792,32 @@ export function hasParentRoute(
650
792
  (d) => d.variableName,
651
793
  ]).filter((d) => d.routePath !== `/${rootPathId}`)
652
794
 
653
- for (const route of sortedNodes) {
654
- if (route.routePath === '/') continue
795
+ if (node.isLayout) {
796
+ for (const route of sortedNodes) {
797
+ if (route.routePath === '/') continue
655
798
 
656
- if (
657
- routePathToCheck.startsWith(`${route.routePath}/`) &&
658
- route.routePath !== routePathToCheck
659
- ) {
660
- return route
799
+ // a layout route's parent has to exactly match the layout route minus, the layout segment
800
+ const exactParentRoutePath = removeLastSegmentFromPath(node.routePath)
801
+ if (route.routePath === exactParentRoutePath) {
802
+ return route
803
+ }
804
+ }
805
+ } else {
806
+ for (const route of sortedNodes) {
807
+ if (route.routePath === '/') continue
808
+
809
+ if (
810
+ routePathToCheck.startsWith(`${route.routePath}/`) &&
811
+ route.routePath !== routePathToCheck
812
+ ) {
813
+ return route
814
+ }
661
815
  }
662
816
  }
817
+
663
818
  const segments = routePathToCheck.split('/')
664
819
  segments.pop() // Remove the last segment
665
820
  const parentRoutePath = segments.join('/')
666
821
 
667
- return hasParentRoute(routes, parentRoutePath)
822
+ return hasParentRoute(routes, node, parentRoutePath)
668
823
  }