@tanstack/router-generator 1.5.4

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.
@@ -0,0 +1,559 @@
1
+ import path from 'path'
2
+ import * as fs from 'fs/promises'
3
+ import * as prettier from 'prettier'
4
+ import { Config } from './config'
5
+ import { cleanPath, trimPathLeft } from './utils'
6
+
7
+ let latestTask = 0
8
+ export const rootPathId = '__root'
9
+ export const fileRouteRegex = /new\s+FileRoute\(([^)]*)\)/g
10
+
11
+ export type RouteNode = {
12
+ filePath: string
13
+ fullPath: string
14
+ variableName: string
15
+ routePath?: string
16
+ cleanedPath?: string
17
+ path?: string
18
+ isNonPath?: boolean
19
+ isNonLayout?: boolean
20
+ isRoute?: boolean
21
+ isLoader?: boolean
22
+ isComponent?: boolean
23
+ isErrorComponent?: boolean
24
+ isPendingComponent?: boolean
25
+ isVirtual?: boolean
26
+ isRoot?: boolean
27
+ children?: RouteNode[]
28
+ parent?: RouteNode
29
+ }
30
+
31
+ async function getRouteNodes(config: Config) {
32
+ const { routeFilePrefix, routeFileIgnorePrefix } = config
33
+
34
+ let routeNodes: RouteNode[] = []
35
+
36
+ async function recurse(dir: string) {
37
+ const fullDir = path.resolve(config.routesDirectory, dir)
38
+ let dirList = await fs.readdir(fullDir, { withFileTypes: true })
39
+
40
+ dirList = dirList.filter((d) => {
41
+ if (
42
+ d.name.startsWith('.') ||
43
+ (routeFileIgnorePrefix && d.name.startsWith(routeFileIgnorePrefix))
44
+ ) {
45
+ return false
46
+ }
47
+
48
+ if (routeFilePrefix) {
49
+ return d.name.startsWith(routeFilePrefix)
50
+ }
51
+
52
+ return true
53
+ })
54
+
55
+ await Promise.all(
56
+ dirList.map(async (dirent) => {
57
+ const fullPath = path.join(fullDir, dirent.name)
58
+ const relativePath = path.join(dir, dirent.name)
59
+
60
+ if (dirent.isDirectory()) {
61
+ await recurse(relativePath)
62
+ } else if (fullPath.match(/\.(tsx|ts|jsx|js)$/)) {
63
+ const filePath = replaceBackslash(path.join(dir, dirent.name))
64
+ const filePathNoExt = removeExt(filePath)
65
+ let routePath =
66
+ cleanPath(`/${filePathNoExt.split('.').join('/')}`) || ''
67
+ const variableName = routePathToVariable(routePath)
68
+
69
+ // Remove the index from the route path and
70
+ // if the route path is empty, use `/'
71
+
72
+ let isRoute = routePath?.endsWith('/route')
73
+ let isComponent = routePath?.endsWith('/component')
74
+ let isErrorComponent = routePath?.endsWith('/errorComponent')
75
+ let isPendingComponent = routePath?.endsWith('/pendingComponent')
76
+ let isLoader = routePath?.endsWith('/loader')
77
+
78
+ routePath = routePath?.replace(
79
+ /\/(component|errorComponent|pendingComponent|loader|route)$/,
80
+ '',
81
+ )
82
+
83
+ if (routePath === 'index') {
84
+ routePath = '/'
85
+ }
86
+
87
+ routePath = routePath.replace(/\/index$/, '/') || '/'
88
+
89
+ routeNodes.push({
90
+ filePath,
91
+ fullPath,
92
+ routePath,
93
+ variableName,
94
+ isRoute,
95
+ isComponent,
96
+ isErrorComponent,
97
+ isPendingComponent,
98
+ isLoader,
99
+ })
100
+ }
101
+ }),
102
+ )
103
+
104
+ return routeNodes
105
+ }
106
+
107
+ await recurse('./')
108
+
109
+ return routeNodes
110
+ }
111
+
112
+ let first = false
113
+ let skipMessage = false
114
+
115
+ type RouteSubNode = {
116
+ component?: RouteNode
117
+ errorComponent?: RouteNode
118
+ pendingComponent?: RouteNode
119
+ loader?: RouteNode
120
+ }
121
+
122
+ export async function generator(config: Config) {
123
+ console.log()
124
+
125
+ if (!first) {
126
+ console.log('🔄 Generating routes...')
127
+ first = true
128
+ } else if (skipMessage) {
129
+ skipMessage = false
130
+ } else {
131
+ console.log('♻️ Regenerating routes...')
132
+ }
133
+
134
+ const taskId = latestTask + 1
135
+ latestTask = taskId
136
+
137
+ const checkLatest = () => {
138
+ if (latestTask !== taskId) {
139
+ skipMessage = true
140
+ return false
141
+ }
142
+
143
+ return true
144
+ }
145
+
146
+ const start = Date.now()
147
+ const routePathIdPrefix = config.routeFilePrefix ?? ''
148
+
149
+ const preRouteNodes = multiSortBy(await getRouteNodes(config), [
150
+ (d) => (d.routePath === '/' ? -1 : 1),
151
+ (d) => d.routePath?.split('/').length,
152
+ (d) => (d.filePath?.match(/[./]index[.]/) ? 1 : -1),
153
+ (d) =>
154
+ d.filePath?.match(
155
+ /[./](component|errorComponent|pendingComponent|loader)[.]/,
156
+ )
157
+ ? 1
158
+ : -1,
159
+ (d) => (d.filePath?.match(/[./]route[.]/) ? -1 : 1),
160
+ (d) => (d.routePath?.endsWith('/') ? -1 : 1),
161
+ (d) => d.routePath,
162
+ ]).filter(
163
+ (d) => ![`/${routePathIdPrefix + rootPathId}`].includes(d.routePath || ''),
164
+ )
165
+
166
+ const routeTree: RouteNode[] = []
167
+ const routePiecesByPath: Record<string, RouteSubNode> = {}
168
+
169
+ // Loop over the flat list of routeNodes and
170
+ // build up a tree based on the routeNodes' routePath
171
+ let routeNodes: RouteNode[] = []
172
+
173
+ const handleNode = (node: RouteNode) => {
174
+ const parentRoute = hasParentRoute(routeNodes, node.routePath)
175
+ if (parentRoute) node.parent = parentRoute
176
+
177
+ node.path = node.parent
178
+ ? node.routePath?.replace(node.parent.routePath!, '') || '/'
179
+ : node.routePath
180
+
181
+ const trimmedPath = trimPathLeft(node.path ?? '')
182
+
183
+ const split = trimmedPath?.split('/') ?? []
184
+ let first = split[0] ?? trimmedPath ?? ''
185
+
186
+ node.isNonPath = first.startsWith('_')
187
+ node.isNonLayout = first.endsWith('_')
188
+
189
+ node.cleanedPath = removeUnderscores(node.path) ?? ''
190
+
191
+ if (
192
+ !node.isVirtual &&
193
+ (node.isLoader ||
194
+ node.isComponent ||
195
+ node.isErrorComponent ||
196
+ node.isPendingComponent)
197
+ ) {
198
+ routePiecesByPath[node.routePath!] =
199
+ routePiecesByPath[node.routePath!] || {}
200
+
201
+ routePiecesByPath[node.routePath!]![
202
+ node.isLoader
203
+ ? 'loader'
204
+ : node.isErrorComponent
205
+ ? 'errorComponent'
206
+ : node.isPendingComponent
207
+ ? 'pendingComponent'
208
+ : 'component'
209
+ ] = node
210
+
211
+ const anchorRoute = routeNodes.find((d) => d.routePath === node.routePath)
212
+
213
+ if (!anchorRoute) {
214
+ handleNode({
215
+ ...node,
216
+ isVirtual: true,
217
+ isLoader: false,
218
+ isComponent: false,
219
+ isErrorComponent: false,
220
+ isPendingComponent: false,
221
+ })
222
+ }
223
+ return
224
+ }
225
+
226
+ if (node.parent) {
227
+ node.parent.children = node.parent.children ?? []
228
+ node.parent.children.push(node)
229
+ } else {
230
+ routeTree.push(node)
231
+ }
232
+
233
+ routeNodes.push(node)
234
+ }
235
+
236
+ preRouteNodes.forEach((node) => handleNode(node))
237
+
238
+ async function buildRouteConfig(
239
+ nodes: RouteNode[],
240
+ depth = 1,
241
+ ): Promise<string> {
242
+ const children = nodes.map(async (node) => {
243
+ const routeCode = await fs.readFile(node.fullPath, 'utf-8')
244
+
245
+ // Ensure the boilerplate for the route exists
246
+ if (node.isRoot) {
247
+ return
248
+ }
249
+
250
+ // Ensure that new FileRoute(anything?) is replaced with FileRoute(${node.routePath})
251
+ // routePath can contain $ characters, which have special meaning when used in replace
252
+ // so we have to escape it by turning all $ into $$. But since we do it through a replace call
253
+ // we have to double escape it into $$$$. For more information, see
254
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement
255
+ const escapedRoutePath = removeTrailingUnderscores(
256
+ node.routePath?.replaceAll('$', '$$$$') ?? '',
257
+ )
258
+ const quote = config.quoteStyle === 'single' ? `'` : `"`
259
+ const replaced = routeCode.replace(
260
+ fileRouteRegex,
261
+ `new FileRoute(${quote}${escapedRoutePath}${quote})`,
262
+ )
263
+
264
+ if (replaced !== routeCode) {
265
+ await fs.writeFile(node.fullPath, replaced)
266
+ }
267
+
268
+ const route = `${node.variableName}Route`
269
+
270
+ if (node.children?.length) {
271
+ const childConfigs = await buildRouteConfig(node.children, depth + 1)
272
+ return `${route}.addChildren([${spaces(depth * 4)}${childConfigs}])`
273
+ }
274
+
275
+ return route
276
+ })
277
+
278
+ return (await Promise.all(children)).filter(Boolean).join(`,`)
279
+ }
280
+
281
+ const routeConfigChildrenText = await buildRouteConfig(routeTree)
282
+
283
+ const sortedRouteNodes = multiSortBy(routeNodes, [
284
+ (d) =>
285
+ d.routePath?.includes(`/${routePathIdPrefix + rootPathId}`) ? -1 : 1,
286
+ (d) => d.routePath?.split('/').length,
287
+ (d) => (d.routePath?.endsWith("index'") ? -1 : 1),
288
+ (d) => d,
289
+ ])
290
+
291
+ const imports = Object.entries({
292
+ FileRoute: sortedRouteNodes.some((d) => d.isVirtual),
293
+ lazyFn: sortedRouteNodes.some(
294
+ (node) => routePiecesByPath[node.routePath!]?.loader,
295
+ ),
296
+ lazyRouteComponent: sortedRouteNodes.some(
297
+ (node) =>
298
+ routePiecesByPath[node.routePath!]?.component ||
299
+ routePiecesByPath[node.routePath!]?.errorComponent ||
300
+ routePiecesByPath[node.routePath!]?.pendingComponent,
301
+ ),
302
+ })
303
+ .filter((d) => d[1])
304
+ .map((d) => d[0])
305
+
306
+ const virtualRouteNodes = sortedRouteNodes.filter((d) => d.isVirtual)
307
+
308
+ const routeImports = [
309
+ '// This file is auto-generated by TanStack Router',
310
+ imports.length
311
+ ? `import { ${imports.join(', ')} } from '@tanstack/react-router'\n`
312
+ : '',
313
+ '// Import Routes',
314
+ [
315
+ `import { Route as rootRoute } from './${replaceBackslash(
316
+ path.relative(
317
+ path.dirname(config.generatedRouteTree),
318
+ path.resolve(config.routesDirectory, routePathIdPrefix + rootPathId),
319
+ ),
320
+ )}'`,
321
+ ...sortedRouteNodes
322
+ .filter((d) => !d.isVirtual)
323
+ .map((node) => {
324
+ return `import { Route as ${
325
+ node.variableName
326
+ }Import } from './${replaceBackslash(
327
+ removeExt(
328
+ path.relative(
329
+ path.dirname(config.generatedRouteTree),
330
+ path.resolve(config.routesDirectory, node.filePath),
331
+ ),
332
+ ),
333
+ )}'`
334
+ }),
335
+ ].join('\n'),
336
+ virtualRouteNodes.length ? '// Create Virtual Routes' : '',
337
+ virtualRouteNodes
338
+ .map((node) => {
339
+ return `const ${
340
+ node.variableName
341
+ }Import = new FileRoute('${removeTrailingUnderscores(
342
+ node.routePath,
343
+ )}').createRoute()`
344
+ })
345
+ .join('\n'),
346
+ '// Create/Update Routes',
347
+ sortedRouteNodes
348
+ .map((node) => {
349
+ const loaderNode = routePiecesByPath[node.routePath!]?.loader
350
+ const componentNode = routePiecesByPath[node.routePath!]?.component
351
+ const errorComponentNode =
352
+ routePiecesByPath[node.routePath!]?.errorComponent
353
+ const pendingComponentNode =
354
+ routePiecesByPath[node.routePath!]?.pendingComponent
355
+
356
+ return [
357
+ `const ${node.variableName}Route = ${node.variableName}Import.update({
358
+ ${[
359
+ node.isNonPath
360
+ ? `id: '${node.path}'`
361
+ : `path: '${node.cleanedPath}'`,
362
+ `getParentRoute: () => ${node.parent?.variableName ?? 'root'}Route`,
363
+ ]
364
+ .filter(Boolean)
365
+ .join(',')}
366
+ } as any)`,
367
+ loaderNode
368
+ ? `.updateLoader({ loader: lazyFn(() => import('./${replaceBackslash(
369
+ removeExt(
370
+ path.relative(
371
+ path.dirname(config.generatedRouteTree),
372
+ path.resolve(config.routesDirectory, loaderNode.filePath),
373
+ ),
374
+ ),
375
+ )}'), 'loader') })`
376
+ : '',
377
+ componentNode || errorComponentNode || pendingComponentNode
378
+ ? `.update({
379
+ ${(
380
+ [
381
+ ['component', componentNode],
382
+ ['errorComponent', errorComponentNode],
383
+ ['pendingComponent', pendingComponentNode],
384
+ ] as const
385
+ )
386
+ .filter((d) => d[1])
387
+ .map((d) => {
388
+ return `${
389
+ d[0]
390
+ }: lazyRouteComponent(() => import('./${replaceBackslash(
391
+ removeExt(
392
+ path.relative(
393
+ path.dirname(config.generatedRouteTree),
394
+ path.resolve(config.routesDirectory, d[1]!.filePath),
395
+ ),
396
+ ),
397
+ )}'), '${d[0]}')`
398
+ })
399
+ .join('\n,')}
400
+ })`
401
+ : '',
402
+ ].join('')
403
+ })
404
+ .join('\n\n'),
405
+ '// Populate the FileRoutesByPath interface',
406
+ `declare module '@tanstack/react-router' {
407
+ interface FileRoutesByPath {
408
+ ${routeNodes
409
+ .map((routeNode) => {
410
+ return `'${removeTrailingUnderscores(routeNode.routePath)}': {
411
+ preLoaderRoute: typeof ${routeNode.variableName}Import
412
+ parentRoute: typeof ${
413
+ routeNode.parent?.variableName
414
+ ? `${routeNode.parent?.variableName}Import`
415
+ : 'rootRoute'
416
+ }
417
+ }`
418
+ })
419
+ .join('\n')}
420
+ }
421
+ }`,
422
+ '// Create and export the route tree',
423
+ `export const routeTree = rootRoute.addChildren([${routeConfigChildrenText}])`,
424
+ ]
425
+ .filter(Boolean)
426
+ .join('\n\n')
427
+
428
+ const routeConfigFileContent = await prettier.format(routeImports, {
429
+ semi: false,
430
+ singleQuote: config.quoteStyle === 'single',
431
+ parser: 'typescript',
432
+ })
433
+
434
+ const routeTreeContent = await fs
435
+ .readFile(path.resolve(config.generatedRouteTree), 'utf-8')
436
+ .catch((err: any) => {
437
+ if (err.code === 'ENOENT') {
438
+ return undefined
439
+ }
440
+ throw err
441
+ })
442
+
443
+ if (!checkLatest()) return
444
+
445
+ if (routeTreeContent !== routeConfigFileContent) {
446
+ await fs.mkdir(path.dirname(path.resolve(config.generatedRouteTree)), {
447
+ recursive: true,
448
+ })
449
+ if (!checkLatest()) return
450
+ await fs.writeFile(
451
+ path.resolve(config.generatedRouteTree),
452
+ routeConfigFileContent,
453
+ )
454
+ }
455
+
456
+ console.log(
457
+ `🌲 Processed ${routeNodes.length} routes in ${Date.now() - start}ms`,
458
+ )
459
+ }
460
+
461
+ function routePathToVariable(d: string): string {
462
+ return (
463
+ removeUnderscores(d)
464
+ ?.replace(/\/\$\//g, '/splat/')
465
+ ?.replace(/\$$/g, 'splat')
466
+ ?.replace(/\$/g, '')
467
+ ?.split(/[/-]/g)
468
+ .map((d, i) => (i > 0 ? capitalize(d) : d))
469
+ .join('')
470
+ .replace(/([^a-zA-Z0-9]|[\.])/gm, '') ?? ''
471
+ )
472
+ }
473
+
474
+ export function removeExt(d: string) {
475
+ return d.substring(0, d.lastIndexOf('.')) || d
476
+ }
477
+
478
+ function spaces(d: number): string {
479
+ return Array.from({ length: d })
480
+ .map(() => ' ')
481
+ .join('')
482
+ }
483
+
484
+ export function multiSortBy<T>(
485
+ arr: T[],
486
+ accessors: ((item: T) => any)[] = [(d) => d],
487
+ ): T[] {
488
+ return arr
489
+ .map((d, i) => [d, i] as const)
490
+ .sort(([a, ai], [b, bi]) => {
491
+ for (const accessor of accessors) {
492
+ const ao = accessor(a)
493
+ const bo = accessor(b)
494
+
495
+ if (typeof ao === 'undefined') {
496
+ if (typeof bo === 'undefined') {
497
+ continue
498
+ }
499
+ return 1
500
+ }
501
+
502
+ if (ao === bo) {
503
+ continue
504
+ }
505
+
506
+ return ao > bo ? 1 : -1
507
+ }
508
+
509
+ return ai - bi
510
+ })
511
+ .map(([d]) => d)
512
+ }
513
+
514
+ function capitalize(s: string) {
515
+ if (typeof s !== 'string') return ''
516
+ return s.charAt(0).toUpperCase() + s.slice(1)
517
+ }
518
+
519
+ function removeUnderscores(s?: string) {
520
+ return s?.replace(/(^_|_$)/, '').replace(/(\/_|_\/)/, '/')
521
+ }
522
+
523
+ function removeTrailingUnderscores(s?: string) {
524
+ return s?.replace(/(_$)/, '').replace(/(_\/)/, '/')
525
+ }
526
+
527
+ function replaceBackslash(s: string) {
528
+ return s.replace(/\\/gi, '/')
529
+ }
530
+
531
+ export function hasParentRoute(
532
+ routes: RouteNode[],
533
+ routePathToCheck: string | undefined,
534
+ ): RouteNode | null {
535
+ if (!routePathToCheck || routePathToCheck === '/') {
536
+ return null
537
+ }
538
+
539
+ const sortedNodes = multiSortBy(routes, [
540
+ (d) => d.routePath!.length * -1,
541
+ (d) => d.variableName,
542
+ ]).filter((d) => d.routePath !== `/${rootPathId}`)
543
+
544
+ for (const route of sortedNodes) {
545
+ if (route.routePath === '/') continue
546
+
547
+ if (
548
+ routePathToCheck.startsWith(`${route.routePath}/`) &&
549
+ route.routePath !== routePathToCheck
550
+ ) {
551
+ return route
552
+ }
553
+ }
554
+ const segments = routePathToCheck.split('/')
555
+ segments.pop() // Remove the last segment
556
+ const parentRoutePath = segments.join('/')
557
+
558
+ return hasParentRoute(routes, parentRoutePath)
559
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { type Config, configSchema, getConfig } from './config'
2
+ export { generator } from './generator'
package/src/utils.ts ADDED
@@ -0,0 +1,8 @@
1
+ export function cleanPath(path: string) {
2
+ // remove double slashes
3
+ return path.replace(/\/{2,}/g, '/')
4
+ }
5
+
6
+ export function trimPathLeft(path: string) {
7
+ return path === '/' ? path : path.replace(/^\/{1,}/, '')
8
+ }