@tanstack/router-generator 1.121.0-alpha.22 → 1.121.0-alpha.26

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.
Files changed (90) hide show
  1. package/dist/cjs/config.cjs +21 -5
  2. package/dist/cjs/config.cjs.map +1 -1
  3. package/dist/cjs/config.d.cts +7 -3
  4. package/dist/cjs/filesystem/physical/getRouteNodes.cjs +5 -3
  5. package/dist/cjs/filesystem/physical/getRouteNodes.cjs.map +1 -1
  6. package/dist/cjs/filesystem/virtual/getRouteNodes.cjs +1 -1
  7. package/dist/cjs/filesystem/virtual/getRouteNodes.cjs.map +1 -1
  8. package/dist/cjs/generator.cjs +825 -664
  9. package/dist/cjs/generator.cjs.map +1 -1
  10. package/dist/cjs/generator.d.cts +78 -1
  11. package/dist/cjs/index.cjs +5 -2
  12. package/dist/cjs/index.cjs.map +1 -1
  13. package/dist/cjs/index.d.cts +7 -3
  14. package/dist/cjs/logger.cjs +37 -0
  15. package/dist/cjs/logger.cjs.map +1 -0
  16. package/dist/cjs/logger.d.cts +10 -0
  17. package/dist/cjs/plugin/default-generator-plugin.cjs +88 -0
  18. package/dist/cjs/plugin/default-generator-plugin.cjs.map +1 -0
  19. package/dist/cjs/plugin/default-generator-plugin.d.cts +2 -0
  20. package/dist/cjs/plugin/types.d.cts +46 -0
  21. package/dist/cjs/template.cjs +10 -10
  22. package/dist/cjs/template.cjs.map +1 -1
  23. package/dist/cjs/template.d.cts +2 -2
  24. package/dist/cjs/transform/default-transform-plugin.cjs +95 -0
  25. package/dist/cjs/transform/default-transform-plugin.cjs.map +1 -0
  26. package/dist/cjs/transform/default-transform-plugin.d.cts +2 -0
  27. package/dist/cjs/transform/transform.cjs +356 -0
  28. package/dist/cjs/transform/transform.cjs.map +1 -0
  29. package/dist/cjs/transform/transform.d.cts +4 -0
  30. package/dist/cjs/transform/types.d.cts +43 -0
  31. package/dist/cjs/transform/utils.cjs +36 -0
  32. package/dist/cjs/transform/utils.cjs.map +1 -0
  33. package/dist/cjs/transform/utils.d.cts +2 -0
  34. package/dist/cjs/types.d.cts +22 -0
  35. package/dist/cjs/utils.cjs +237 -40
  36. package/dist/cjs/utils.cjs.map +1 -1
  37. package/dist/cjs/utils.d.cts +76 -9
  38. package/dist/esm/config.d.ts +7 -3
  39. package/dist/esm/config.js +19 -3
  40. package/dist/esm/config.js.map +1 -1
  41. package/dist/esm/filesystem/physical/getRouteNodes.js +3 -1
  42. package/dist/esm/filesystem/physical/getRouteNodes.js.map +1 -1
  43. package/dist/esm/filesystem/virtual/getRouteNodes.js +1 -1
  44. package/dist/esm/filesystem/virtual/getRouteNodes.js.map +1 -1
  45. package/dist/esm/generator.d.ts +78 -1
  46. package/dist/esm/generator.js +814 -652
  47. package/dist/esm/generator.js.map +1 -1
  48. package/dist/esm/index.d.ts +7 -3
  49. package/dist/esm/index.js +7 -4
  50. package/dist/esm/index.js.map +1 -1
  51. package/dist/esm/logger.d.ts +10 -0
  52. package/dist/esm/logger.js +37 -0
  53. package/dist/esm/logger.js.map +1 -0
  54. package/dist/esm/plugin/default-generator-plugin.d.ts +2 -0
  55. package/dist/esm/plugin/default-generator-plugin.js +88 -0
  56. package/dist/esm/plugin/default-generator-plugin.js.map +1 -0
  57. package/dist/esm/plugin/types.d.ts +46 -0
  58. package/dist/esm/template.d.ts +2 -2
  59. package/dist/esm/template.js +10 -10
  60. package/dist/esm/template.js.map +1 -1
  61. package/dist/esm/transform/default-transform-plugin.d.ts +2 -0
  62. package/dist/esm/transform/default-transform-plugin.js +95 -0
  63. package/dist/esm/transform/default-transform-plugin.js.map +1 -0
  64. package/dist/esm/transform/transform.d.ts +4 -0
  65. package/dist/esm/transform/transform.js +356 -0
  66. package/dist/esm/transform/transform.js.map +1 -0
  67. package/dist/esm/transform/types.d.ts +43 -0
  68. package/dist/esm/transform/utils.d.ts +2 -0
  69. package/dist/esm/transform/utils.js +36 -0
  70. package/dist/esm/transform/utils.js.map +1 -0
  71. package/dist/esm/types.d.ts +22 -0
  72. package/dist/esm/utils.d.ts +76 -9
  73. package/dist/esm/utils.js +237 -40
  74. package/dist/esm/utils.js.map +1 -1
  75. package/package.json +7 -8
  76. package/src/config.ts +21 -2
  77. package/src/filesystem/physical/getRouteNodes.ts +2 -1
  78. package/src/filesystem/virtual/getRouteNodes.ts +1 -1
  79. package/src/generator.ts +1106 -934
  80. package/src/index.ts +25 -3
  81. package/src/logger.ts +43 -0
  82. package/src/plugin/default-generator-plugin.ts +96 -0
  83. package/src/plugin/types.ts +51 -0
  84. package/src/template.ts +33 -12
  85. package/src/transform/default-transform-plugin.ts +103 -0
  86. package/src/transform/transform.ts +439 -0
  87. package/src/transform/types.ts +50 -0
  88. package/src/transform/utils.ts +42 -0
  89. package/src/types.ts +25 -0
  90. package/src/utils.ts +351 -36
package/src/generator.ts CHANGED
@@ -1,370 +1,1152 @@
1
1
  import path from 'node:path'
2
- import * as fs from 'node:fs'
3
2
  import * as fsp from 'node:fs/promises'
3
+ import { mkdtempSync } from 'node:fs'
4
+ import crypto from 'node:crypto'
5
+ import { deepEqual, rootRouteId } from '@tanstack/router-core'
6
+ import { logging } from './logger'
7
+ import { getRouteNodes as physicalGetRouteNodes } from './filesystem/physical/getRouteNodes'
8
+ import { getRouteNodes as virtualGetRouteNodes } from './filesystem/virtual/getRouteNodes'
9
+ import { rootPathId } from './filesystem/physical/rootPathId'
4
10
  import {
11
+ buildImportString,
12
+ buildRouteTreeConfig,
13
+ checkFileExists,
14
+ createRouteNodesByFullPath,
15
+ createRouteNodesById,
16
+ createRouteNodesByTo,
17
+ determineNodePath,
18
+ findParent,
5
19
  format,
6
- logging,
20
+ getResolvedRouteNodeVariableName,
21
+ hasParentRoute,
22
+ inferFullPath,
23
+ inferPath,
24
+ isRouteNodeValidForAugmentation,
25
+ lowerCaseFirstChar,
26
+ mergeImportDeclarations,
7
27
  multiSortBy,
8
28
  removeExt,
29
+ removeGroups,
30
+ removeLastSegmentFromPath,
31
+ removeLayoutSegments,
9
32
  removeUnderscores,
10
33
  replaceBackslash,
11
34
  resetRegex,
12
35
  routePathToVariable,
13
36
  trimPathLeft,
14
- writeIfDifferent,
15
37
  } from './utils'
16
- import { getRouteNodes as physicalGetRouteNodes } from './filesystem/physical/getRouteNodes'
17
- import { getRouteNodes as virtualGetRouteNodes } from './filesystem/virtual/getRouteNodes'
18
- import { rootPathId } from './filesystem/physical/rootPathId'
19
38
  import { fillTemplate, getTargetTemplate } from './template'
20
- import type { FsRouteType, GetRouteNodesResult, RouteNode } from './types'
39
+ import { transform } from './transform/transform'
40
+ import { defaultGeneratorPlugin } from './plugin/default-generator-plugin'
41
+ import type {
42
+ GeneratorPlugin,
43
+ GeneratorPluginWithTransform,
44
+ } from './plugin/types'
45
+ import type { TargetTemplate } from './template'
46
+ import type {
47
+ FsRouteType,
48
+ GetRouteNodesResult,
49
+ HandleNodeAccumulator,
50
+ ImportDeclaration,
51
+ RouteNode,
52
+ } from './types'
21
53
  import type { Config } from './config'
54
+ import type { Logger } from './logger'
55
+ import type { TransformPlugin } from './transform/types'
56
+
57
+ interface fs {
58
+ stat: (filePath: string) => Promise<{ mtimeMs: bigint }>
59
+ mkdtempSync: (prefix: string) => string
60
+ rename: (oldPath: string, newPath: string) => Promise<void>
61
+ writeFile: (filePath: string, content: string) => Promise<void>
62
+ readFile: (
63
+ filePath: string,
64
+ ) => Promise<
65
+ { stat: { mtimeMs: bigint }; fileContent: string } | 'file-not-existing'
66
+ >
67
+ }
68
+
69
+ const DefaultFileSystem: fs = {
70
+ stat: (filePath) => fsp.stat(filePath, { bigint: true }),
71
+ mkdtempSync: mkdtempSync,
72
+ rename: (oldPath, newPath) => fsp.rename(oldPath, newPath),
73
+ writeFile: (filePath, content) => fsp.writeFile(filePath, content),
74
+ readFile: async (filePath: string) => {
75
+ try {
76
+ const fileHandle = await fsp.open(filePath, 'r')
77
+ const stat = await fileHandle.stat({ bigint: true })
78
+ const fileContent = (await fileHandle.readFile()).toString()
79
+ await fileHandle.close()
80
+ return { stat, fileContent }
81
+ } catch (e: any) {
82
+ if ('code' in e) {
83
+ if (e.code === 'ENOENT') {
84
+ return 'file-not-existing'
85
+ }
86
+ }
87
+ throw e
88
+ }
89
+ },
90
+ }
91
+
92
+ interface Rerun {
93
+ rerun: true
94
+ msg?: string
95
+ event: GeneratorEvent
96
+ }
97
+ function rerun(opts: { msg?: string; event?: GeneratorEvent }): Rerun {
98
+ const { event, ...rest } = opts
99
+ return { rerun: true, event: event ?? { type: 'rerun' }, ...rest }
100
+ }
101
+
102
+ function isRerun(result: unknown): result is Rerun {
103
+ return (
104
+ typeof result === 'object' &&
105
+ result !== null &&
106
+ 'rerun' in result &&
107
+ result.rerun === true
108
+ )
109
+ }
22
110
 
23
- // Maybe import this from `@tanstack/router-core` in the future???
24
- const rootRouteId = '__root__'
111
+ export type FileEventType = 'create' | 'update' | 'delete'
112
+ export type FileEvent = {
113
+ type: FileEventType
114
+ path: string
115
+ }
116
+ export type GeneratorEvent = FileEvent | { type: 'rerun' }
25
117
 
26
- let latestTask = 0
27
- const routeGroupPatternRegex = /\(.+\)/g
28
- const possiblyNestedRouteGroupPatternRegex = /\([^/]+\)\/?/g
118
+ type FileCacheChange<TCacheEntry extends GeneratorCacheEntry> =
119
+ | {
120
+ result: false
121
+ cacheEntry: TCacheEntry
122
+ }
123
+ | { result: true; mtimeMs: bigint; cacheEntry: TCacheEntry }
124
+ | {
125
+ result: 'file-not-in-cache'
126
+ }
127
+ | {
128
+ result: 'cannot-stat-file'
129
+ }
29
130
 
30
- let isFirst = false
31
- let skipMessage = false
131
+ interface GeneratorCacheEntry {
132
+ mtimeMs: bigint
133
+ fileContent: string
134
+ }
32
135
 
33
- type RouteSubNode = {
34
- component?: RouteNode
35
- errorComponent?: RouteNode
36
- pendingComponent?: RouteNode
37
- loader?: RouteNode
38
- lazy?: RouteNode
136
+ interface RouteNodeCacheEntry extends GeneratorCacheEntry {
137
+ exports: Array<string>
39
138
  }
40
139
 
41
- export async function generator(config: Config, root: string) {
42
- const ROUTE_TEMPLATE = getTargetTemplate(config.target)
43
- const logger = logging({ disabled: config.disableLogging })
44
- logger.log('')
45
-
46
- if (!isFirst) {
47
- logger.log('♻️ Generating routes...')
48
- isFirst = true
49
- } else if (skipMessage) {
50
- skipMessage = false
51
- } else {
52
- logger.log('♻️ Regenerating routes...')
140
+ type GeneratorRouteNodeCache = Map</** filePath **/ string, RouteNodeCacheEntry>
141
+
142
+ export class Generator {
143
+ /**
144
+ * why do we have two caches for the route files?
145
+ * During processing, we READ from the cache and WRITE to the shadow cache.
146
+ *
147
+ * After a route file is processed, we write to the shadow cache.
148
+ * If during processing we bail out and re-run, we don't lose this modification
149
+ * but still can track whether the file contributed changes and thus the route tree file needs to be regenerated.
150
+ * After all files are processed, we swap the shadow cache with the main cache and initialize a new shadow cache.
151
+ * That way we also ensure deleted/renamed files don't stay in the cache forever.
152
+ */
153
+ private routeNodeCache: GeneratorRouteNodeCache = new Map()
154
+ private routeNodeShadowCache: GeneratorRouteNodeCache = new Map()
155
+
156
+ private routeTreeFileCache: GeneratorCacheEntry | undefined
157
+
158
+ public config: Config
159
+ public targetTemplate: TargetTemplate
160
+
161
+ private root: string
162
+ private routesDirectoryPath: string
163
+ private tmpDir: string
164
+ private fs: fs
165
+ private logger: Logger
166
+ private generatedRouteTreePath: string
167
+ private runPromise: Promise<void> | undefined
168
+ private fileEventQueue: Array<GeneratorEvent> = []
169
+ private plugins: Array<GeneratorPlugin> = [defaultGeneratorPlugin()]
170
+ private pluginsWithTransform: Array<GeneratorPluginWithTransform> = []
171
+ // this is just a cache for the transform plugins since we need them for each route file that is to be processed
172
+ private transformPlugins: Array<TransformPlugin> = []
173
+ private routeGroupPatternRegex = /\(.+\)/g
174
+
175
+ constructor(opts: { config: Config; root: string; fs?: fs }) {
176
+ this.config = opts.config
177
+ this.logger = logging({ disabled: this.config.disableLogging })
178
+ this.root = opts.root
179
+ this.fs = opts.fs || DefaultFileSystem
180
+ this.tmpDir = this.fs.mkdtempSync(
181
+ path.join(this.config.tmpDir, 'tanstack-router-'),
182
+ )
183
+ this.generatedRouteTreePath = path.resolve(this.config.generatedRouteTree)
184
+ this.targetTemplate = getTargetTemplate(this.config)
185
+
186
+ this.routesDirectoryPath = this.getRoutesDirectoryPath()
187
+ this.plugins.push(...(opts.config.plugins || []))
188
+ this.plugins.forEach((plugin) => {
189
+ if ('transformPlugin' in plugin) {
190
+ if (this.pluginsWithTransform.find((p) => p.name === plugin.name)) {
191
+ throw new Error(
192
+ `Plugin with name "${plugin.name}" is already registered for export ${plugin.transformPlugin.exportName}!`,
193
+ )
194
+ }
195
+ this.pluginsWithTransform.push(plugin)
196
+ this.transformPlugins.push(plugin.transformPlugin)
197
+ }
198
+ })
53
199
  }
54
200
 
55
- const taskId = latestTask + 1
56
- latestTask = taskId
201
+ private getRoutesDirectoryPath() {
202
+ return path.isAbsolute(this.config.routesDirectory)
203
+ ? this.config.routesDirectory
204
+ : path.resolve(this.root, this.config.routesDirectory)
205
+ }
57
206
 
58
- const checkLatest = () => {
59
- if (latestTask !== taskId) {
60
- skipMessage = true
61
- return false
207
+ public async run(event?: GeneratorEvent): Promise<void> {
208
+ // we are only interested in FileEvents that affect either the generated route tree or files inside the routes folder
209
+ if (event && event.type !== 'rerun') {
210
+ if (
211
+ !(
212
+ event.path === this.generatedRouteTreePath ||
213
+ event.path.startsWith(this.routesDirectoryPath)
214
+ )
215
+ ) {
216
+ return
217
+ }
218
+ }
219
+ this.fileEventQueue.push(event ?? { type: 'rerun' })
220
+ // only allow a single run at a time
221
+ if (this.runPromise) {
222
+ return this.runPromise
62
223
  }
63
224
 
64
- return true
225
+ this.runPromise = (async () => {
226
+ do {
227
+ // synchronously copy and clear the queue since we are going to iterate asynchronously over it
228
+ // and while we do so, a new event could be put into the queue
229
+ const tempQueue = this.fileEventQueue
230
+ this.fileEventQueue = []
231
+ // if we only have 'update' events in the queue
232
+ // and we already have the affected files' latest state in our cache, we can exit early
233
+ const remainingEvents = (
234
+ await Promise.all(
235
+ tempQueue.map(async (e) => {
236
+ if (e.type === 'update') {
237
+ let cacheEntry
238
+ if (e.path === this.generatedRouteTreePath) {
239
+ cacheEntry = this.routeTreeFileCache
240
+ } else {
241
+ // we only check the routeNodeCache here
242
+ // if the file's state is only up-to-date in the shadow cache we need to re-run
243
+ cacheEntry = this.routeNodeCache.get(e.path)
244
+ }
245
+ const change = await this.didFileChangeComparedToCache(
246
+ { path: e.path },
247
+ cacheEntry,
248
+ )
249
+ if (change.result === false) {
250
+ return null
251
+ }
252
+ }
253
+ return e
254
+ }),
255
+ )
256
+ ).filter((e) => e !== null)
257
+
258
+ if (remainingEvents.length === 0) {
259
+ break
260
+ }
261
+
262
+ try {
263
+ const start = performance.now()
264
+ await this.generatorInternal()
265
+ const end = performance.now()
266
+ this.logger.info(
267
+ `Generated route tree in ${Math.round(end - start)}ms`,
268
+ )
269
+ } catch (err) {
270
+ const errArray = !Array.isArray(err) ? [err] : err
271
+
272
+ const recoverableErrors = errArray.filter((e) => isRerun(e))
273
+ if (recoverableErrors.length === errArray.length) {
274
+ this.fileEventQueue.push(...recoverableErrors.map((e) => e.event))
275
+ recoverableErrors.forEach((e) => {
276
+ if (e.msg) {
277
+ this.logger.info(e.msg)
278
+ }
279
+ })
280
+ } else {
281
+ const unrecoverableErrors = errArray.filter((e) => !isRerun(e))
282
+ this.runPromise = undefined
283
+ throw new Error(
284
+ unrecoverableErrors.map((e) => (e as Error).message).join(),
285
+ )
286
+ }
287
+ }
288
+ } while (this.fileEventQueue.length)
289
+ this.runPromise = undefined
290
+ })()
291
+ return this.runPromise
65
292
  }
66
293
 
67
- const start = Date.now()
294
+ private async generatorInternal() {
295
+ let writeRouteTreeFile = false as boolean
68
296
 
69
- const TYPES_DISABLED = config.disableTypes
297
+ let getRouteNodesResult: GetRouteNodesResult
298
+
299
+ if (this.config.virtualRouteConfig) {
300
+ getRouteNodesResult = await virtualGetRouteNodes(this.config, this.root)
301
+ } else {
302
+ getRouteNodesResult = await physicalGetRouteNodes(this.config, this.root)
303
+ }
70
304
 
71
- let getRouteNodesResult: GetRouteNodesResult
305
+ const { rootRouteNode, routeNodes: beforeRouteNodes } = getRouteNodesResult
306
+ if (rootRouteNode === undefined) {
307
+ let errorMessage = `rootRouteNode must not be undefined. Make sure you've added your root route into the route-tree.`
308
+ if (!this.config.virtualRouteConfig) {
309
+ errorMessage += `\nMake sure that you add a "${rootPathId}.${this.config.disableTypes ? 'js' : 'tsx'}" file to your routes directory.\nAdd the file in: "${this.config.routesDirectory}/${rootPathId}.${this.config.disableTypes ? 'js' : 'tsx'}"`
310
+ }
311
+ throw new Error(errorMessage)
312
+ }
72
313
 
73
- if (config.virtualRouteConfig) {
74
- getRouteNodesResult = await virtualGetRouteNodes(config, root)
75
- } else {
76
- getRouteNodesResult = await physicalGetRouteNodes(config, root)
77
- }
314
+ writeRouteTreeFile = await this.handleRootNode(rootRouteNode)
315
+
316
+ const preRouteNodes = multiSortBy(beforeRouteNodes, [
317
+ (d) => (d.routePath === '/' ? -1 : 1),
318
+ (d) => d.routePath?.split('/').length,
319
+ (d) =>
320
+ d.filePath.match(new RegExp(`[./]${this.config.indexToken}[.]`))
321
+ ? 1
322
+ : -1,
323
+ (d) =>
324
+ d.filePath.match(
325
+ /[./](component|errorComponent|pendingComponent|loader|lazy)[.]/,
326
+ )
327
+ ? 1
328
+ : -1,
329
+ (d) =>
330
+ d.filePath.match(new RegExp(`[./]${this.config.routeToken}[.]`))
331
+ ? -1
332
+ : 1,
333
+ (d) => (d.routePath?.endsWith('/') ? -1 : 1),
334
+ (d) => d.routePath,
335
+ ]).filter((d) => ![`/${rootPathId}`].includes(d.routePath || ''))
336
+
337
+ const routeFileAllResult = await Promise.allSettled(
338
+ preRouteNodes
339
+ // only process routes that are backed by an actual file
340
+ .filter((n) => !n.isVirtualParentRoute && !n.isVirtual)
341
+ .map((n) => this.processRouteNodeFile(n)),
342
+ )
78
343
 
79
- const { rootRouteNode, routeNodes: beforeRouteNodes } = getRouteNodesResult
80
- if (rootRouteNode === undefined) {
81
- let errorMessage = `rootRouteNode must not be undefined. Make sure you've added your root route into the route-tree.`
82
- if (!config.virtualRouteConfig) {
83
- errorMessage += `\nMake sure that you add a "${rootPathId}.${config.disableTypes ? 'js' : 'tsx'}" file to your routes directory.\nAdd the file in: "${config.routesDirectory}/${rootPathId}.${config.disableTypes ? 'js' : 'tsx'}"`
344
+ const rejections = routeFileAllResult.filter(
345
+ (result) => result.status === 'rejected',
346
+ )
347
+ if (rejections.length > 0) {
348
+ throw rejections.map((e) => e.reason)
84
349
  }
85
- throw new Error(errorMessage)
86
- }
87
350
 
88
- const preRouteNodes = multiSortBy(beforeRouteNodes, [
89
- (d) => (d.routePath === '/' ? -1 : 1),
90
- (d) => d.routePath?.split('/').length,
91
- (d) =>
92
- d.filePath.match(new RegExp(`[./]${config.indexToken}[.]`)) ? 1 : -1,
93
- (d) =>
94
- d.filePath.match(
95
- /[./](component|errorComponent|pendingComponent|loader|lazy)[.]/,
96
- )
97
- ? 1
98
- : -1,
99
- (d) =>
100
- d.filePath.match(new RegExp(`[./]${config.routeToken}[.]`)) ? -1 : 1,
101
- (d) => (d.routePath?.endsWith('/') ? -1 : 1),
102
- (d) => d.routePath,
103
- ]).filter((d) => ![`/${rootPathId}`].includes(d.routePath || ''))
104
-
105
- const routeTree: Array<RouteNode> = []
106
- const routePiecesByPath: Record<string, RouteSubNode> = {}
107
-
108
- // Loop over the flat list of routeNodes and
109
- // build up a tree based on the routeNodes' routePath
110
- const routeNodes: Array<RouteNode> = []
111
-
112
- // the handleRootNode function is not being collapsed into the handleNode function
113
- // because it requires only a subset of the logic that the handleNode function requires
114
- // and it's easier to read and maintain this way
115
- const handleRootNode = async (node?: RouteNode) => {
116
- if (!node) {
117
- // currently this is not being handled, but it could be in the future
118
- // for example to handle a virtual root route
119
- return
351
+ const routeFileResult = routeFileAllResult.flatMap((result) => {
352
+ if (result.status === 'fulfilled' && result.value !== null) {
353
+ return result.value
354
+ }
355
+ return []
356
+ })
357
+
358
+ routeFileResult.forEach((result) => {
359
+ if (!result.node.exports?.length) {
360
+ this.logger.warn(
361
+ `Route file "${result.cacheEntry.fileContent}" does not export any route piece. This is likely a mistake.`,
362
+ )
363
+ }
364
+ })
365
+ if (routeFileResult.find((r) => r.shouldWriteTree)) {
366
+ writeRouteTreeFile = true
120
367
  }
121
368
 
122
- // from here on, we are only handling the root node that's present in the file system
123
- const routeCode = fs.readFileSync(node.fullPath, 'utf-8')
369
+ // this is the first time the generator runs, so read in the route tree file if it exists yet
370
+ if (!this.routeTreeFileCache) {
371
+ const routeTreeFile = await this.fs.readFile(this.generatedRouteTreePath)
372
+ if (routeTreeFile !== 'file-not-existing') {
373
+ this.routeTreeFileCache = {
374
+ fileContent: routeTreeFile.fileContent,
375
+ mtimeMs: routeTreeFile.stat.mtimeMs,
376
+ }
377
+ }
378
+ writeRouteTreeFile = true
379
+ }
124
380
 
125
- if (!routeCode) {
126
- const _rootTemplate = ROUTE_TEMPLATE.rootRoute
127
- const replaced = await fillTemplate(config, _rootTemplate.template(), {
128
- tsrImports: _rootTemplate.imports.tsrImports(),
129
- tsrPath: rootPathId,
130
- tsrExportStart: _rootTemplate.imports.tsrExportStart(),
131
- tsrExportEnd: _rootTemplate.imports.tsrExportEnd(),
132
- })
381
+ if (!writeRouteTreeFile) {
382
+ return
383
+ }
133
384
 
134
- await writeIfDifferent(
135
- node.fullPath,
136
- '', // Empty string because the file doesn't exist yet
137
- replaced,
138
- {
139
- beforeWrite: () => {
140
- logger.log(`🟡 Creating ${node.fullPath}`)
385
+ let routeTreeContent = this.buildRouteTreeFileContent(
386
+ rootRouteNode,
387
+ preRouteNodes,
388
+ routeFileResult,
389
+ )
390
+ routeTreeContent = this.config.enableRouteTreeFormatting
391
+ ? await format(routeTreeContent, this.config)
392
+ : routeTreeContent
393
+
394
+ let newMtimeMs: bigint | undefined
395
+ if (this.routeTreeFileCache) {
396
+ if (this.routeTreeFileCache.fileContent === routeTreeContent) {
397
+ // existing route tree file is already up-to-date, don't write it
398
+ // we should only get here in the initial run when the route cache is not filled yet
399
+ } else {
400
+ const newRouteTreeFileStat = await this.safeFileWrite({
401
+ filePath: this.generatedRouteTreePath,
402
+ newContent: routeTreeContent,
403
+ strategy: {
404
+ type: 'mtime',
405
+ expectedMtimeMs: this.routeTreeFileCache.mtimeMs,
141
406
  },
407
+ })
408
+ newMtimeMs = newRouteTreeFileStat.mtimeMs
409
+ }
410
+ } else {
411
+ const newRouteTreeFileStat = await this.safeFileWrite({
412
+ filePath: this.generatedRouteTreePath,
413
+ newContent: routeTreeContent,
414
+ strategy: {
415
+ type: 'new-file',
142
416
  },
143
- )
417
+ })
418
+ newMtimeMs = newRouteTreeFileStat.mtimeMs
419
+ }
420
+
421
+ if (newMtimeMs !== undefined) {
422
+ this.routeTreeFileCache = {
423
+ fileContent: routeTreeContent,
424
+ mtimeMs: newMtimeMs,
425
+ }
144
426
  }
427
+
428
+ // now that we have finished this run, we can finally swap the caches
429
+ this.routeNodeCache = this.routeNodeShadowCache
430
+ this.routeNodeShadowCache = new Map()
145
431
  }
146
432
 
147
- await handleRootNode(rootRouteNode)
433
+ private buildRouteTreeFileContent(
434
+ rootRouteNode: RouteNode,
435
+ preRouteNodes: Array<RouteNode>,
436
+ routeFileResult: Array<{
437
+ cacheEntry: RouteNodeCacheEntry
438
+ node: RouteNode
439
+ }>,
440
+ ) {
441
+ const getImportForRouteNode = (node: RouteNode, exportName: string) => {
442
+ if (node.exports?.includes(exportName)) {
443
+ return {
444
+ source: `./${this.getImportPath(node)}`,
445
+ specifiers: [
446
+ {
447
+ imported: exportName,
448
+ local: `${node.variableName}${exportName}Import`,
449
+ },
450
+ ],
451
+ } satisfies ImportDeclaration
452
+ }
453
+ return undefined
454
+ }
148
455
 
149
- const handleNode = async (node: RouteNode) => {
150
- // Do not remove this as we need to set the lastIndex to 0 as it
151
- // is necessary to reset the regex's index when using the global flag
152
- // otherwise it might not match the next time it's used
153
- resetRegex(routeGroupPatternRegex)
456
+ const buildRouteTreeForExport = (plugin: GeneratorPluginWithTransform) => {
457
+ const exportName = plugin.transformPlugin.exportName
458
+ const acc: HandleNodeAccumulator = {
459
+ routeTree: [],
460
+ routeNodes: [],
461
+ routePiecesByPath: {},
462
+ }
463
+ for (const node of preRouteNodes) {
464
+ if (node.exports?.includes(plugin.transformPlugin.exportName)) {
465
+ this.handleNode(node, acc)
466
+ }
467
+ }
154
468
 
155
- let parentRoute = hasParentRoute(routeNodes, node, node.routePath)
469
+ const sortedRouteNodes = multiSortBy(acc.routeNodes, [
470
+ (d) => (d.routePath?.includes(`/${rootPathId}`) ? -1 : 1),
471
+ (d) => d.routePath?.split('/').length,
472
+ (d) => (d.routePath?.endsWith(this.config.indexToken) ? -1 : 1),
473
+ (d) => d,
474
+ ])
475
+
476
+ const pluginConfig = plugin.config({
477
+ generator: this,
478
+ rootRouteNode,
479
+ sortedRouteNodes,
480
+ })
156
481
 
157
- // if the parent route is a virtual parent route, we need to find the real parent route
158
- if (parentRoute?.isVirtualParentRoute && parentRoute.children?.length) {
159
- // only if this sub-parent route returns a valid parent route, we use it, if not leave it as it
160
- const possibleParentRoute = hasParentRoute(
161
- parentRoute.children,
162
- node,
163
- node.routePath,
482
+ const routeImports = sortedRouteNodes
483
+ .filter((d) => !d.isVirtual)
484
+ .flatMap((node) => getImportForRouteNode(node, exportName) ?? [])
485
+
486
+ const hasMatchingRouteFiles =
487
+ acc.routeNodes.length > 0 || rootRouteNode.exports?.includes(exportName)
488
+
489
+ const virtualRouteNodes = sortedRouteNodes
490
+ .filter((d) => d.isVirtual)
491
+ .map((node) => {
492
+ return `const ${
493
+ node.variableName
494
+ }${exportName}Import = ${plugin.createVirtualRouteCode({ node })}`
495
+ })
496
+ if (
497
+ !rootRouteNode.exports?.includes(exportName) &&
498
+ pluginConfig.virtualRootRoute
499
+ ) {
500
+ virtualRouteNodes.unshift(
501
+ `const ${rootRouteNode.variableName}${exportName}Import = ${plugin.createRootRouteCode()}`,
502
+ )
503
+ }
504
+
505
+ const imports = plugin.imports({
506
+ sortedRouteNodes,
507
+ acc,
508
+ generator: this,
509
+ rootRouteNode,
510
+ })
511
+
512
+ const routeTreeConfig = buildRouteTreeConfig(
513
+ acc.routeTree,
514
+ exportName,
515
+ this.config.disableTypes,
164
516
  )
165
- if (possibleParentRoute) {
166
- parentRoute = possibleParentRoute
517
+
518
+ const createUpdateRoutes = sortedRouteNodes.map((node) => {
519
+ const loaderNode = acc.routePiecesByPath[node.routePath!]?.loader
520
+ const componentNode = acc.routePiecesByPath[node.routePath!]?.component
521
+ const errorComponentNode =
522
+ acc.routePiecesByPath[node.routePath!]?.errorComponent
523
+ const pendingComponentNode =
524
+ acc.routePiecesByPath[node.routePath!]?.pendingComponent
525
+ const lazyComponentNode = acc.routePiecesByPath[node.routePath!]?.lazy
526
+
527
+ return [
528
+ [
529
+ `const ${node.variableName}${exportName} = ${node.variableName}${exportName}Import.update({
530
+ ${[
531
+ `id: '${node.path}'`,
532
+ !node.isNonPath ? `path: '${node.cleanedPath}'` : undefined,
533
+ `getParentRoute: () => ${findParent(node, exportName)}`,
534
+ ]
535
+ .filter(Boolean)
536
+ .join(',')}
537
+ }${this.config.disableTypes ? '' : 'as any'})`,
538
+ loaderNode
539
+ ? `.updateLoader({ loader: lazyFn(() => import('./${replaceBackslash(
540
+ removeExt(
541
+ path.relative(
542
+ path.dirname(this.config.generatedRouteTree),
543
+ path.resolve(
544
+ this.config.routesDirectory,
545
+ loaderNode.filePath,
546
+ ),
547
+ ),
548
+ this.config.addExtensions,
549
+ ),
550
+ )}'), 'loader') })`
551
+ : '',
552
+ componentNode || errorComponentNode || pendingComponentNode
553
+ ? `.update({
554
+ ${(
555
+ [
556
+ ['component', componentNode],
557
+ ['errorComponent', errorComponentNode],
558
+ ['pendingComponent', pendingComponentNode],
559
+ ] as const
560
+ )
561
+ .filter((d) => d[1])
562
+ .map((d) => {
563
+ return `${
564
+ d[0]
565
+ }: lazyRouteComponent(() => import('./${replaceBackslash(
566
+ removeExt(
567
+ path.relative(
568
+ path.dirname(this.config.generatedRouteTree),
569
+ path.resolve(
570
+ this.config.routesDirectory,
571
+ d[1]!.filePath,
572
+ ),
573
+ ),
574
+ this.config.addExtensions,
575
+ ),
576
+ )}'), '${d[0]}')`
577
+ })
578
+ .join('\n,')}
579
+ })`
580
+ : '',
581
+ lazyComponentNode
582
+ ? `.lazy(() => import('./${replaceBackslash(
583
+ removeExt(
584
+ path.relative(
585
+ path.dirname(this.config.generatedRouteTree),
586
+ path.resolve(
587
+ this.config.routesDirectory,
588
+ lazyComponentNode.filePath,
589
+ ),
590
+ ),
591
+ this.config.addExtensions,
592
+ ),
593
+ )}').then((d) => d.${exportName}))`
594
+ : '',
595
+ ].join(''),
596
+ ].join('\n\n')
597
+ })
598
+
599
+ let fileRoutesByPathInterfacePerPlugin = ''
600
+ let fileRoutesByFullPathPerPlugin = ''
601
+
602
+ if (!this.config.disableTypes && hasMatchingRouteFiles) {
603
+ fileRoutesByFullPathPerPlugin = [
604
+ `export interface File${exportName}sByFullPath {
605
+ ${[...createRouteNodesByFullPath(acc.routeNodes).entries()].map(
606
+ ([fullPath, routeNode]) => {
607
+ return `'${fullPath}': typeof ${getResolvedRouteNodeVariableName(routeNode, exportName)}`
608
+ },
609
+ )}
610
+ }`,
611
+ `export interface File${exportName}sByTo {
612
+ ${[...createRouteNodesByTo(acc.routeNodes).entries()].map(([to, routeNode]) => {
613
+ return `'${to}': typeof ${getResolvedRouteNodeVariableName(routeNode, exportName)}`
614
+ })}
615
+ }`,
616
+ `export interface File${exportName}sById {
617
+ '${rootRouteId}': typeof root${exportName}Import,
618
+ ${[...createRouteNodesById(acc.routeNodes).entries()].map(([id, routeNode]) => {
619
+ return `'${id}': typeof ${getResolvedRouteNodeVariableName(routeNode, exportName)}`
620
+ })}
621
+ }`,
622
+ `export interface File${exportName}Types {
623
+ file${exportName}sByFullPath: File${exportName}sByFullPath
624
+ fullPaths: ${acc.routeNodes.length > 0 ? [...createRouteNodesByFullPath(acc.routeNodes).keys()].map((fullPath) => `'${fullPath}'`).join('|') : 'never'}
625
+ file${exportName}sByTo: File${exportName}sByTo
626
+ to: ${acc.routeNodes.length > 0 ? [...createRouteNodesByTo(acc.routeNodes).keys()].map((to) => `'${to}'`).join('|') : 'never'}
627
+ id: ${[`'${rootRouteId}'`, ...[...createRouteNodesById(acc.routeNodes).keys()].map((id) => `'${id}'`)].join('|')}
628
+ file${exportName}sById: File${exportName}sById
629
+ }`,
630
+ `export interface Root${exportName}Children {
631
+ ${acc.routeTree.map((child) => `${child.variableName}${exportName}: typeof ${getResolvedRouteNodeVariableName(child, exportName)}`).join(',')}
632
+ }`,
633
+ ].join('\n')
634
+
635
+ fileRoutesByPathInterfacePerPlugin = buildFileRoutesByPathInterface({
636
+ ...plugin.moduleAugmentation({ generator: this }),
637
+ routeNodes: preRouteNodes,
638
+ exportName,
639
+ })
640
+ }
641
+
642
+ let routeTree = ''
643
+ if (hasMatchingRouteFiles) {
644
+ routeTree = [
645
+ `const root${exportName}Children${this.config.disableTypes ? '' : `: Root${exportName}Children`} = {
646
+ ${acc.routeTree
647
+ .map(
648
+ (child) =>
649
+ `${child.variableName}${exportName}: ${getResolvedRouteNodeVariableName(child, exportName)}`,
650
+ )
651
+ .join(',')}
652
+ }`,
653
+ `export const ${lowerCaseFirstChar(exportName)}Tree = root${exportName}Import._addFileChildren(root${exportName}Children)${this.config.disableTypes ? '' : `._addFileTypes<File${exportName}Types>()`}`,
654
+ ].join('\n')
655
+ }
656
+
657
+ return {
658
+ routeImports,
659
+ sortedRouteNodes,
660
+ acc,
661
+ virtualRouteNodes,
662
+ routeTreeConfig,
663
+ routeTree,
664
+ imports,
665
+ createUpdateRoutes,
666
+ fileRoutesByFullPathPerPlugin,
667
+ fileRoutesByPathInterfacePerPlugin,
167
668
  }
168
669
  }
169
670
 
170
- if (parentRoute) node.parent = parentRoute
671
+ const routeTrees = this.pluginsWithTransform.map((plugin) => ({
672
+ exportName: plugin.transformPlugin.exportName,
673
+ ...buildRouteTreeForExport(plugin),
674
+ }))
171
675
 
172
- node.path = determineNodePath(node)
676
+ this.plugins.map((plugin) => {
677
+ return plugin.onRouteTreesChanged?.({
678
+ routeTrees,
679
+ rootRouteNode,
680
+ generator: this,
681
+ })
682
+ })
173
683
 
174
- const trimmedPath = trimPathLeft(node.path ?? '')
684
+ let mergedImports = mergeImportDeclarations(
685
+ routeTrees.flatMap((d) => d.imports),
686
+ )
687
+ if (this.config.disableTypes) {
688
+ mergedImports = mergedImports.filter((d) => d.importKind !== 'type')
689
+ }
175
690
 
176
- const split = trimmedPath.split('/')
177
- const lastRouteSegment = split[split.length - 1] ?? trimmedPath
691
+ const importStatements = mergedImports.map(buildImportString)
692
+
693
+ let moduleAugmentation = ''
694
+ if (this.config.verboseFileRoutes === false && !this.config.disableTypes) {
695
+ moduleAugmentation = routeFileResult
696
+ .map(({ node }) => {
697
+ const getModuleDeclaration = (routeNode?: RouteNode) => {
698
+ if (!isRouteNodeValidForAugmentation(routeNode)) {
699
+ return ''
700
+ }
701
+ const moduleAugmentation = this.pluginsWithTransform
702
+ .map((plugin) => {
703
+ return plugin.routeModuleAugmentation({
704
+ routeNode,
705
+ })
706
+ })
707
+ .filter(Boolean)
708
+ .join('\n')
178
709
 
179
- node.isNonPath =
180
- lastRouteSegment.startsWith('_') ||
181
- routeGroupPatternRegex.test(lastRouteSegment)
710
+ return `declare module './${this.getImportPath(routeNode)}' {
711
+ ${moduleAugmentation}
712
+ }`
713
+ }
714
+ return getModuleDeclaration(node)
715
+ })
716
+ .join('\n')
717
+ }
182
718
 
183
- node.cleanedPath = removeGroups(
184
- removeUnderscores(removeLayoutSegments(node.path)) ?? '',
719
+ const routeImports = routeTrees.flatMap((t) => t.routeImports)
720
+ const rootRouteImports = this.pluginsWithTransform.flatMap(
721
+ (p) =>
722
+ getImportForRouteNode(rootRouteNode, p.transformPlugin.exportName) ??
723
+ [],
185
724
  )
725
+ if (rootRouteImports.length > 0) {
726
+ routeImports.unshift(...rootRouteImports)
727
+ }
728
+ const routeTreeContent = [
729
+ ...this.config.routeTreeFileHeader,
730
+ `// This file was automatically generated by TanStack Router.
731
+ // You should NOT make any changes in this file as it will be overwritten.
732
+ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.`,
733
+ [...importStatements].join('\n'),
734
+ mergeImportDeclarations(routeImports).map(buildImportString).join('\n'),
735
+ routeTrees.flatMap((t) => t.virtualRouteNodes).join('\n'),
736
+ routeTrees.flatMap((t) => t.createUpdateRoutes).join('\n'),
737
+
738
+ routeTrees.map((t) => t.fileRoutesByFullPathPerPlugin).join('\n'),
739
+ routeTrees.map((t) => t.fileRoutesByPathInterfacePerPlugin).join('\n'),
740
+ moduleAugmentation,
741
+ routeTrees.flatMap((t) => t.routeTreeConfig).join('\n'),
742
+ routeTrees.map((t) => t.routeTree).join('\n'),
743
+ ...this.config.routeTreeFileFooter,
744
+ ]
745
+ .filter(Boolean)
746
+ .join('\n\n')
747
+ return routeTreeContent
748
+ }
186
749
 
187
- // Ensure the boilerplate for the route exists, which can be skipped for virtual parent routes and virtual routes
188
- if (!node.isVirtualParentRoute && !node.isVirtual) {
189
- const routeCode = fs.readFileSync(node.fullPath, 'utf-8')
750
+ private getImportPath(node: RouteNode) {
751
+ return replaceBackslash(
752
+ removeExt(
753
+ path.relative(
754
+ path.dirname(this.config.generatedRouteTree),
755
+ path.resolve(this.config.routesDirectory, node.filePath),
756
+ ),
757
+ this.config.addExtensions,
758
+ ),
759
+ )
760
+ }
190
761
 
191
- const escapedRoutePath = node.routePath?.replaceAll('$', '$$') ?? ''
762
+ private async processRouteNodeFile(node: RouteNode): Promise<{
763
+ shouldWriteTree: boolean
764
+ cacheEntry: RouteNodeCacheEntry
765
+ node: RouteNode
766
+ } | null> {
767
+ const result = await this.isRouteFileCacheFresh(node)
192
768
 
193
- let replaced = routeCode
769
+ if (result.status === 'fresh') {
770
+ node.exports = result.cacheEntry.exports
771
+ return {
772
+ node,
773
+ shouldWriteTree: result.exportsChanged,
774
+ cacheEntry: result.cacheEntry,
775
+ }
776
+ }
194
777
 
195
- const tRouteTemplate = ROUTE_TEMPLATE.route
196
- const tLazyRouteTemplate = ROUTE_TEMPLATE.lazyRoute
778
+ const existingRouteFile = await this.fs.readFile(node.fullPath)
779
+ if (existingRouteFile === 'file-not-existing') {
780
+ throw new Error(`⚠️ File ${node.fullPath} does not exist`)
781
+ }
197
782
 
198
- if (!routeCode) {
199
- // Creating a new lazy route file
200
- if (node._fsRouteType === 'lazy') {
201
- // Check by default check if the user has a specific lazy route template
202
- // If not, check if the user has a route template and use that instead
203
- replaced = await fillTemplate(
204
- config,
205
- (config.customScaffolding?.lazyRouteTemplate ||
206
- config.customScaffolding?.routeTemplate) ??
207
- tLazyRouteTemplate.template(),
208
- {
209
- tsrImports: tLazyRouteTemplate.imports.tsrImports(),
210
- tsrPath: escapedRoutePath.replaceAll(/\{(.+)\}/gm, '$1'),
211
- tsrExportStart:
212
- tLazyRouteTemplate.imports.tsrExportStart(escapedRoutePath),
213
- tsrExportEnd: tLazyRouteTemplate.imports.tsrExportEnd(),
214
- },
215
- )
216
- } else if (
217
- // Creating a new normal route file
218
- (['layout', 'static'] satisfies Array<FsRouteType>).some(
219
- (d) => d === node._fsRouteType,
220
- ) ||
221
- (
222
- [
223
- 'component',
224
- 'pendingComponent',
225
- 'errorComponent',
226
- 'loader',
227
- ] satisfies Array<FsRouteType>
228
- ).every((d) => d !== node._fsRouteType)
229
- ) {
230
- replaced = await fillTemplate(
231
- config,
232
- config.customScaffolding?.routeTemplate ??
233
- tRouteTemplate.template(),
234
- {
235
- tsrImports: tRouteTemplate.imports.tsrImports(),
236
- tsrPath: escapedRoutePath.replaceAll(/\{(.+)\}/gm, '$1'),
237
- tsrExportStart:
238
- tRouteTemplate.imports.tsrExportStart(escapedRoutePath),
239
- tsrExportEnd: tRouteTemplate.imports.tsrExportEnd(),
240
- },
241
- )
242
- }
243
- } else if (config.verboseFileRoutes === false) {
244
- // Check if the route file has a Route export
245
- if (
246
- !routeCode
247
- .split('\n')
248
- .some((line) => line.trim().startsWith('export const Route'))
249
- ) {
250
- return
251
- }
783
+ const updatedCacheEntry: RouteNodeCacheEntry = {
784
+ fileContent: existingRouteFile.fileContent,
785
+ mtimeMs: existingRouteFile.stat.mtimeMs,
786
+ exports: [],
787
+ }
252
788
 
253
- // Update the existing route file
254
- replaced = routeCode
255
- .replace(
256
- /(FileRoute\(\s*['"])([^\s]*)(['"],?\s*\))/g,
257
- (_, p1, __, p3) => `${p1}${escapedRoutePath}${p3}`,
258
- )
259
- .replace(
260
- new RegExp(
261
- `(import\\s*\\{)(.*)(create(Lazy)?FileRoute)(.*)(\\}\\s*from\\s*['"]@tanstack\\/${ROUTE_TEMPLATE.subPkg}['"])`,
262
- 'gs',
263
- ),
264
- (_, p1, p2, ___, ____, p5, p6) => {
265
- const beforeCreateFileRoute = () => {
266
- if (!p2) return ''
267
-
268
- let trimmed = p2.trim()
269
-
270
- if (trimmed.endsWith(',')) {
271
- trimmed = trimmed.slice(0, -1)
272
- }
789
+ const escapedRoutePath = node.routePath?.replaceAll('$', '$$') ?? ''
790
+
791
+ let shouldWriteRouteFile = false
792
+ // now we need to either scaffold the file or transform it
793
+ if (!existingRouteFile.fileContent) {
794
+ shouldWriteRouteFile = true
795
+ // Creating a new lazy route file
796
+ if (node._fsRouteType === 'lazy') {
797
+ const tLazyRouteTemplate = this.targetTemplate.lazyRoute
798
+ // Check by default check if the user has a specific lazy route template
799
+ // If not, check if the user has a route template and use that instead
800
+ updatedCacheEntry.fileContent = await fillTemplate(
801
+ this.config,
802
+ (this.config.customScaffolding?.lazyRouteTemplate ||
803
+ this.config.customScaffolding?.routeTemplate) ??
804
+ tLazyRouteTemplate.template(),
805
+ {
806
+ tsrImports: tLazyRouteTemplate.imports.tsrImports(),
807
+ tsrPath: escapedRoutePath.replaceAll(/\{(.+)\}/gm, '$1'),
808
+ tsrExportStart:
809
+ tLazyRouteTemplate.imports.tsrExportStart(escapedRoutePath),
810
+ tsrExportEnd: tLazyRouteTemplate.imports.tsrExportEnd(),
811
+ },
812
+ )
813
+ updatedCacheEntry.exports = ['Route']
814
+ } else if (
815
+ // Creating a new normal route file
816
+ (['layout', 'static'] satisfies Array<FsRouteType>).some(
817
+ (d) => d === node._fsRouteType,
818
+ ) ||
819
+ (
820
+ [
821
+ 'component',
822
+ 'pendingComponent',
823
+ 'errorComponent',
824
+ 'loader',
825
+ ] satisfies Array<FsRouteType>
826
+ ).every((d) => d !== node._fsRouteType)
827
+ ) {
828
+ const tRouteTemplate = this.targetTemplate.route
829
+ updatedCacheEntry.fileContent = await fillTemplate(
830
+ this.config,
831
+ this.config.customScaffolding?.routeTemplate ??
832
+ tRouteTemplate.template(),
833
+ {
834
+ tsrImports: tRouteTemplate.imports.tsrImports(),
835
+ tsrPath: escapedRoutePath.replaceAll(/\{(.+)\}/gm, '$1'),
836
+ tsrExportStart:
837
+ tRouteTemplate.imports.tsrExportStart(escapedRoutePath),
838
+ tsrExportEnd: tRouteTemplate.imports.tsrExportEnd(),
839
+ },
840
+ )
841
+ updatedCacheEntry.exports = ['Route']
842
+ } else {
843
+ return null
844
+ }
845
+ } else {
846
+ // transform the file
847
+ const transformResult = await transform({
848
+ source: updatedCacheEntry.fileContent,
849
+ ctx: {
850
+ target: this.config.target,
851
+ routeId: escapedRoutePath,
852
+ lazy: node._fsRouteType === 'lazy',
853
+ verboseFileRoutes: !(this.config.verboseFileRoutes === false),
854
+ },
855
+ plugins: this.transformPlugins,
856
+ })
273
857
 
274
- return trimmed
275
- }
858
+ if (transformResult.result === 'error') {
859
+ throw new Error(
860
+ `Error transforming route file ${node.fullPath}: ${transformResult.error}`,
861
+ )
862
+ }
863
+ updatedCacheEntry.exports = transformResult.exports
864
+ if (transformResult.result === 'modified') {
865
+ updatedCacheEntry.fileContent = transformResult.output
866
+ shouldWriteRouteFile = true
867
+ }
868
+ }
276
869
 
277
- const afterCreateFileRoute = () => {
278
- if (!p5) return ''
870
+ // file was changed
871
+ if (shouldWriteRouteFile) {
872
+ const stats = await this.safeFileWrite({
873
+ filePath: node.fullPath,
874
+ newContent: updatedCacheEntry.fileContent,
875
+ strategy: {
876
+ type: 'mtime',
877
+ expectedMtimeMs: updatedCacheEntry.mtimeMs,
878
+ },
879
+ })
880
+ updatedCacheEntry.mtimeMs = stats.mtimeMs
881
+ }
279
882
 
280
- let trimmed = p5.trim()
883
+ this.routeNodeShadowCache.set(node.fullPath, updatedCacheEntry)
884
+ node.exports = updatedCacheEntry.exports
885
+ const shouldWriteTree = !deepEqual(
886
+ result.cacheEntry?.exports,
887
+ updatedCacheEntry.exports,
888
+ )
889
+ return {
890
+ node,
891
+ shouldWriteTree,
892
+ cacheEntry: updatedCacheEntry,
893
+ }
894
+ }
281
895
 
282
- if (trimmed.startsWith(',')) {
283
- trimmed = trimmed.slice(1)
284
- }
896
+ private async didRouteFileChangeComparedToCache(
897
+ file: {
898
+ path: string
899
+ mtimeMs?: bigint
900
+ },
901
+ cache: 'routeNodeCache' | 'routeNodeShadowCache',
902
+ ): Promise<FileCacheChange<RouteNodeCacheEntry>> {
903
+ const cacheEntry = this[cache].get(file.path)
904
+ return this.didFileChangeComparedToCache(file, cacheEntry)
905
+ }
285
906
 
286
- return trimmed
287
- }
907
+ private async didFileChangeComparedToCache<
908
+ TCacheEntry extends GeneratorCacheEntry,
909
+ >(
910
+ file: {
911
+ path: string
912
+ mtimeMs?: bigint
913
+ },
914
+ cacheEntry: TCacheEntry | undefined,
915
+ ): Promise<FileCacheChange<TCacheEntry>> {
916
+ // for now we rely on the modification time of the file
917
+ // to determine if the file has changed
918
+ // we could also compare the file content but this would be slower as we would have to read the file
919
+
920
+ if (!cacheEntry) {
921
+ return { result: 'file-not-in-cache' }
922
+ }
923
+ let mtimeMs = file.mtimeMs
924
+
925
+ if (mtimeMs === undefined) {
926
+ try {
927
+ const currentStat = await this.fs.stat(file.path)
928
+ mtimeMs = currentStat.mtimeMs
929
+ } catch {
930
+ return { result: 'cannot-stat-file' }
931
+ }
932
+ }
933
+ return { result: mtimeMs !== cacheEntry.mtimeMs, mtimeMs, cacheEntry }
934
+ }
288
935
 
289
- const newImport = () => {
290
- const before = beforeCreateFileRoute()
291
- const after = afterCreateFileRoute()
936
+ private async safeFileWrite(opts: {
937
+ filePath: string
938
+ newContent: string
939
+ strategy:
940
+ | {
941
+ type: 'mtime'
942
+ expectedMtimeMs: bigint
943
+ }
944
+ | {
945
+ type: 'new-file'
946
+ }
947
+ }) {
948
+ const tmpPath = this.getTempFileName(opts.filePath)
949
+ await this.fs.writeFile(tmpPath, opts.newContent)
950
+
951
+ if (opts.strategy.type === 'mtime') {
952
+ const beforeStat = await this.fs.stat(opts.filePath)
953
+ if (beforeStat.mtimeMs !== opts.strategy.expectedMtimeMs) {
954
+ throw rerun({
955
+ msg: `File ${opts.filePath} was modified by another process during processing.`,
956
+ event: { type: 'update', path: opts.filePath },
957
+ })
958
+ }
959
+ } else {
960
+ if (await checkFileExists(opts.filePath)) {
961
+ throw rerun({
962
+ msg: `File ${opts.filePath} already exists. Cannot overwrite.`,
963
+ event: { type: 'update', path: opts.filePath },
964
+ })
965
+ }
966
+ }
292
967
 
293
- if (!before) return after
968
+ const stat = await this.fs.stat(tmpPath)
294
969
 
295
- if (!after) return before
970
+ await this.fs.rename(tmpPath, opts.filePath)
296
971
 
297
- return `${before},${after}`
298
- }
972
+ return stat
973
+ }
299
974
 
300
- const middle = newImport()
975
+ private getTempFileName(filePath: string) {
976
+ const absPath = path.resolve(filePath)
977
+ const hash = crypto.createHash('md5').update(absPath).digest('hex')
978
+ return path.join(this.tmpDir, hash)
979
+ }
301
980
 
302
- if (middle === '') return ''
981
+ private async isRouteFileCacheFresh(node: RouteNode): Promise<
982
+ | {
983
+ status: 'fresh'
984
+ cacheEntry: RouteNodeCacheEntry
985
+ exportsChanged: boolean
986
+ }
987
+ | { status: 'stale'; cacheEntry?: RouteNodeCacheEntry }
988
+ > {
989
+ const fileChangedCache = await this.didRouteFileChangeComparedToCache(
990
+ { path: node.fullPath },
991
+ 'routeNodeCache',
992
+ )
993
+ if (fileChangedCache.result === false) {
994
+ this.routeNodeShadowCache.set(node.fullPath, fileChangedCache.cacheEntry)
995
+ return {
996
+ status: 'fresh',
997
+ exportsChanged: false,
998
+ cacheEntry: fileChangedCache.cacheEntry,
999
+ }
1000
+ }
1001
+ if (fileChangedCache.result === 'cannot-stat-file') {
1002
+ throw new Error(`⚠️ expected route file to exist at ${node.fullPath}`)
1003
+ }
1004
+ const mtimeMs =
1005
+ fileChangedCache.result === true ? fileChangedCache.mtimeMs : undefined
303
1006
 
304
- return `${p1} ${newImport()} ${p6}`
305
- },
306
- )
307
- .replace(
308
- /create(Lazy)?FileRoute(\(\s*['"])([^\s]*)(['"],?\s*\))/g,
309
- (_, __, p2, ___, p4) =>
310
- `${node._fsRouteType === 'lazy' ? 'createLazyFileRoute' : 'createFileRoute'}`,
311
- )
312
- } else {
313
- // Check if the route file has a Route export
1007
+ const shadowCacheFileChange = await this.didRouteFileChangeComparedToCache(
1008
+ { path: node.fullPath, mtimeMs },
1009
+ 'routeNodeShadowCache',
1010
+ )
1011
+
1012
+ if (shadowCacheFileChange.result === 'cannot-stat-file') {
1013
+ throw new Error(`⚠️ expected route file to exist at ${node.fullPath}`)
1014
+ }
1015
+
1016
+ if (shadowCacheFileChange.result === false) {
1017
+ // shadow cache has latest file state already
1018
+ // compare shadowCache against cache to determine whether exports changed
1019
+ // if they didn't, cache is fresh
1020
+ if (fileChangedCache.result === true) {
314
1021
  if (
315
- !routeCode
316
- .split('\n')
317
- .some((line) => line.trim().startsWith('export const Route'))
1022
+ deepEqual(
1023
+ fileChangedCache.cacheEntry.exports,
1024
+ shadowCacheFileChange.cacheEntry.exports,
1025
+ )
318
1026
  ) {
319
- return
1027
+ return {
1028
+ status: 'fresh',
1029
+ exportsChanged: false,
1030
+ cacheEntry: shadowCacheFileChange.cacheEntry,
1031
+ }
320
1032
  }
321
-
322
- // Update the existing route file
323
- replaced = routeCode
324
- // fix wrong ids
325
- .replace(
326
- /(FileRoute\(\s*['"])([^\s]*)(['"],?\s*\))/g,
327
- (_, p1, __, p3) => `${p1}${escapedRoutePath}${p3}`,
328
- )
329
- // fix missing ids
330
- .replace(
331
- /((FileRoute)(\s*)(\({))/g,
332
- (_, __, p2, p3, p4) => `${p2}('${escapedRoutePath}')${p3}${p4}`,
333
- )
334
- .replace(
335
- new RegExp(
336
- `(import\\s*\\{.*)(create(Lazy)?FileRoute)(.*\\}\\s*from\\s*['"]@tanstack\\/${ROUTE_TEMPLATE.subPkg}['"])`,
337
- 'gs',
338
- ),
339
- (_, p1, __, ___, p4) =>
340
- `${p1}${node._fsRouteType === 'lazy' ? 'createLazyFileRoute' : 'createFileRoute'}${p4}`,
341
- )
342
- .replace(
343
- /create(Lazy)?FileRoute(\(\s*['"])([^\s]*)(['"],?\s*\))/g,
344
- (_, __, p2, ___, p4) =>
345
- `${node._fsRouteType === 'lazy' ? 'createLazyFileRoute' : 'createFileRoute'}${p2}${escapedRoutePath}${p4}`,
346
- )
347
-
348
- // check whether the import statement is already present
349
- const regex = new RegExp(
350
- `(import\\s*\\{.*)(create(Lazy)?FileRoute)(.*\\}\\s*from\\s*['"]@tanstack\\/${ROUTE_TEMPLATE.subPkg}['"])`,
351
- 'gm',
352
- )
353
- if (!replaced.match(regex)) {
354
- replaced = [
355
- `import { ${node._fsRouteType === 'lazy' ? 'createLazyFileRoute' : 'createFileRoute'} } from '@tanstack/${ROUTE_TEMPLATE.subPkg}'`,
356
- ...replaced.split('\n'),
357
- ].join('\n')
1033
+ return {
1034
+ status: 'fresh',
1035
+ exportsChanged: true,
1036
+ cacheEntry: shadowCacheFileChange.cacheEntry,
358
1037
  }
359
1038
  }
1039
+ }
1040
+
1041
+ if (fileChangedCache.result === 'file-not-in-cache') {
1042
+ return {
1043
+ status: 'stale',
1044
+ }
1045
+ }
1046
+ return { status: 'stale', cacheEntry: fileChangedCache.cacheEntry }
1047
+ }
1048
+
1049
+ private async handleRootNode(node: RouteNode) {
1050
+ const result = await this.isRouteFileCacheFresh(node)
1051
+
1052
+ if (result.status === 'fresh') {
1053
+ return false
1054
+ }
1055
+ const rootNodeFile = await this.fs.readFile(node.fullPath)
1056
+ if (rootNodeFile === 'file-not-existing') {
1057
+ throw new Error(`⚠️ expected root route to exist at ${node.fullPath}`)
1058
+ }
1059
+
1060
+ const updatedCacheEntry: RouteNodeCacheEntry = {
1061
+ fileContent: rootNodeFile.fileContent,
1062
+ mtimeMs: rootNodeFile.stat.mtimeMs,
1063
+ exports: [],
1064
+ }
1065
+
1066
+ // scaffold the root route
1067
+ if (!rootNodeFile.fileContent) {
1068
+ const rootTemplate = this.targetTemplate.rootRoute
1069
+ const rootRouteContent = await fillTemplate(
1070
+ this.config,
1071
+ rootTemplate.template(),
1072
+ {
1073
+ tsrImports: rootTemplate.imports.tsrImports(),
1074
+ tsrPath: rootPathId,
1075
+ tsrExportStart: rootTemplate.imports.tsrExportStart(),
1076
+ tsrExportEnd: rootTemplate.imports.tsrExportEnd(),
1077
+ },
1078
+ )
360
1079
 
361
- await writeIfDifferent(node.fullPath, routeCode, replaced, {
362
- beforeWrite: () => {
363
- logger.log(`🟡 Updating ${node.fullPath}`)
1080
+ this.logger.log(`🟡 Creating ${node.fullPath}`)
1081
+ const stats = await this.safeFileWrite({
1082
+ filePath: node.fullPath,
1083
+ newContent: rootRouteContent,
1084
+ strategy: {
1085
+ type: 'mtime',
1086
+ expectedMtimeMs: rootNodeFile.stat.mtimeMs,
364
1087
  },
365
1088
  })
1089
+ updatedCacheEntry.fileContent = rootRouteContent
1090
+ updatedCacheEntry.mtimeMs = stats.mtimeMs
1091
+ }
1092
+
1093
+ const rootRouteExports: Array<string> = []
1094
+ for (const plugin of this.pluginsWithTransform) {
1095
+ const exportName = plugin.transformPlugin.exportName
1096
+ if (rootNodeFile.fileContent.includes(`export const ${exportName}`)) {
1097
+ rootRouteExports.push(exportName)
1098
+ }
1099
+ }
1100
+
1101
+ updatedCacheEntry.exports = rootRouteExports
1102
+ node.exports = rootRouteExports
1103
+ this.routeNodeShadowCache.set(node.fullPath, updatedCacheEntry)
1104
+
1105
+ const shouldWriteTree = !deepEqual(
1106
+ result.cacheEntry?.exports,
1107
+ rootRouteExports,
1108
+ )
1109
+ return shouldWriteTree
1110
+ }
1111
+
1112
+ private handleNode(node: RouteNode, acc: HandleNodeAccumulator) {
1113
+ // Do not remove this as we need to set the lastIndex to 0 as it
1114
+ // is necessary to reset the regex's index when using the global flag
1115
+ // otherwise it might not match the next time it's used
1116
+ resetRegex(this.routeGroupPatternRegex)
1117
+
1118
+ let parentRoute = hasParentRoute(acc.routeNodes, node, node.routePath)
1119
+
1120
+ // if the parent route is a virtual parent route, we need to find the real parent route
1121
+ if (parentRoute?.isVirtualParentRoute && parentRoute.children?.length) {
1122
+ // only if this sub-parent route returns a valid parent route, we use it, if not leave it as it
1123
+ const possibleParentRoute = hasParentRoute(
1124
+ parentRoute.children,
1125
+ node,
1126
+ node.routePath,
1127
+ )
1128
+ if (possibleParentRoute) {
1129
+ parentRoute = possibleParentRoute
1130
+ }
366
1131
  }
367
1132
 
1133
+ if (parentRoute) node.parent = parentRoute
1134
+
1135
+ node.path = determineNodePath(node)
1136
+
1137
+ const trimmedPath = trimPathLeft(node.path ?? '')
1138
+
1139
+ const split = trimmedPath.split('/')
1140
+ const lastRouteSegment = split[split.length - 1] ?? trimmedPath
1141
+
1142
+ node.isNonPath =
1143
+ lastRouteSegment.startsWith('_') ||
1144
+ this.routeGroupPatternRegex.test(lastRouteSegment)
1145
+
1146
+ node.cleanedPath = removeGroups(
1147
+ removeUnderscores(removeLayoutSegments(node.path)) ?? '',
1148
+ )
1149
+
368
1150
  if (
369
1151
  !node.isVirtual &&
370
1152
  (
@@ -377,10 +1159,10 @@ export async function generator(config: Config, root: string) {
377
1159
  ] satisfies Array<FsRouteType>
378
1160
  ).some((d) => d === node._fsRouteType)
379
1161
  ) {
380
- routePiecesByPath[node.routePath!] =
381
- routePiecesByPath[node.routePath!] || {}
1162
+ acc.routePiecesByPath[node.routePath!] =
1163
+ acc.routePiecesByPath[node.routePath!] || {}
382
1164
 
383
- routePiecesByPath[node.routePath!]![
1165
+ acc.routePiecesByPath[node.routePath!]![
384
1166
  node._fsRouteType === 'lazy'
385
1167
  ? 'lazy'
386
1168
  : node._fsRouteType === 'loader'
@@ -392,14 +1174,19 @@ export async function generator(config: Config, root: string) {
392
1174
  : 'component'
393
1175
  ] = node
394
1176
 
395
- const anchorRoute = routeNodes.find((d) => d.routePath === node.routePath)
1177
+ const anchorRoute = acc.routeNodes.find(
1178
+ (d) => d.routePath === node.routePath,
1179
+ )
396
1180
 
397
1181
  if (!anchorRoute) {
398
- await handleNode({
399
- ...node,
400
- isVirtual: true,
401
- _fsRouteType: 'static',
402
- })
1182
+ this.handleNode(
1183
+ {
1184
+ ...node,
1185
+ isVirtual: true,
1186
+ _fsRouteType: 'static',
1187
+ },
1188
+ acc,
1189
+ )
403
1190
  }
404
1191
  return
405
1192
  }
@@ -417,7 +1204,7 @@ export async function generator(config: Config, root: string) {
417
1204
  const parentRoutePath = removeLastSegmentFromPath(node.routePath) || '/'
418
1205
  const parentVariableName = routePathToVariable(parentRoutePath)
419
1206
 
420
- const anchorRoute = routeNodes.find(
1207
+ const anchorRoute = acc.routeNodes.find(
421
1208
  (d) => d.routePath === parentRoutePath,
422
1209
  )
423
1210
 
@@ -445,7 +1232,7 @@ export async function generator(config: Config, root: string) {
445
1232
  node.path = determineNodePath(node)
446
1233
  }
447
1234
 
448
- await handleNode(parentNode)
1235
+ this.handleNode(parentNode, acc)
449
1236
  } else {
450
1237
  anchorRoute.children = anchorRoute.children ?? []
451
1238
  anchorRoute.children.push(node)
@@ -460,658 +1247,43 @@ export async function generator(config: Config, root: string) {
460
1247
  node.parent.children.push(node)
461
1248
  }
462
1249
  } else {
463
- routeTree.push(node)
1250
+ acc.routeTree.push(node)
464
1251
  }
465
1252
 
466
- routeNodes.push(node)
467
- }
468
-
469
- for (const node of preRouteNodes) {
470
- await handleNode(node)
1253
+ acc.routeNodes.push(node)
471
1254
  }
1255
+ }
472
1256
 
473
- // This is run against the `preRouteNodes` array since it
474
- // has the flattened Route nodes and not the full tree
475
- // Since TSR allows multiple way of defining a route,
476
- // we need to ensure that a user hasn't defined the
477
- // same route in multiple ways (i.e. `flat`, `nested`, `virtual`)
478
- checkRouteFullPathUniqueness(
479
- preRouteNodes.filter(
480
- (d) => d.children === undefined && 'lazy' !== d._fsRouteType,
481
- ),
482
- config,
483
- )
484
-
485
- function buildRouteTreeConfig(nodes: Array<RouteNode>, depth = 1): string {
486
- const children = nodes.map((node) => {
487
- if (node._fsRouteType === '__root') {
488
- return
489
- }
490
-
491
- if (node._fsRouteType === 'pathless_layout' && !node.children?.length) {
492
- return
493
- }
494
-
495
- const route = `${node.variableName}Route`
496
-
497
- if (node.children?.length) {
498
- const childConfigs = buildRouteTreeConfig(node.children, depth + 1)
499
-
500
- const childrenDeclaration = TYPES_DISABLED
501
- ? ''
502
- : `interface ${route}Children {
503
- ${node.children.map((child) => `${child.variableName}Route: typeof ${getResolvedRouteNodeVariableName(child)}`).join(',')}
504
- }`
505
-
506
- const children = `const ${route}Children${TYPES_DISABLED ? '' : `: ${route}Children`} = {
507
- ${node.children.map((child) => `${child.variableName}Route: ${getResolvedRouteNodeVariableName(child)}`).join(',')}
508
- }`
509
-
510
- const routeWithChildren = `const ${route}WithChildren = ${route}._addFileChildren(${route}Children)`
511
-
512
- return [
513
- childConfigs,
514
- childrenDeclaration,
515
- children,
516
- routeWithChildren,
517
- ].join('\n\n')
518
- }
519
-
520
- return undefined
521
- })
522
-
523
- return children.filter(Boolean).join('\n\n')
524
- }
525
-
526
- const routeConfigChildrenText = buildRouteTreeConfig(routeTree)
527
-
528
- const sortedRouteNodes = multiSortBy(routeNodes, [
529
- (d) => (d.routePath?.includes(`/${rootPathId}`) ? -1 : 1),
530
- (d) => d.routePath?.split('/').length,
531
- (d) => (d.routePath?.endsWith(config.indexToken) ? -1 : 1),
532
- (d) => d,
533
- ])
534
-
535
- const typeImports = Object.entries({
536
- // Used for augmentation of regular routes
537
- CreateFileRoute:
538
- config.verboseFileRoutes === false &&
539
- sortedRouteNodes.some(
540
- (d) => isRouteNodeValidForAugmentation(d) && d._fsRouteType !== 'lazy',
541
- ),
542
- // Used for augmentation of lazy (`.lazy`) routes
543
- CreateLazyFileRoute:
544
- config.verboseFileRoutes === false &&
545
- sortedRouteNodes.some(
546
- (node) =>
547
- routePiecesByPath[node.routePath!]?.lazy &&
548
- isRouteNodeValidForAugmentation(node),
549
- ),
550
- // Used in the process of augmenting the routes
551
- FileRoutesByPath:
552
- config.verboseFileRoutes === false &&
553
- sortedRouteNodes.some((d) => isRouteNodeValidForAugmentation(d)),
554
- })
555
- .filter((d) => d[1])
556
- .map((d) => d[0])
557
- .sort((a, b) => a.localeCompare(b))
558
-
559
- const imports = Object.entries({
560
- createFileRoute: sortedRouteNodes.some((d) => d.isVirtual),
561
- lazyFn: sortedRouteNodes.some(
562
- (node) => routePiecesByPath[node.routePath!]?.loader,
563
- ),
564
- lazyRouteComponent: sortedRouteNodes.some(
565
- (node) =>
566
- routePiecesByPath[node.routePath!]?.component ||
567
- routePiecesByPath[node.routePath!]?.errorComponent ||
568
- routePiecesByPath[node.routePath!]?.pendingComponent,
569
- ),
570
- })
571
- .filter((d) => d[1])
572
- .map((d) => d[0])
573
-
574
- const virtualRouteNodes = sortedRouteNodes.filter((d) => d.isVirtual)
575
-
576
- function getImportPath(node: RouteNode) {
577
- return replaceBackslash(
578
- removeExt(
579
- path.relative(
580
- path.dirname(config.generatedRouteTree),
581
- path.resolve(config.routesDirectory, node.filePath),
582
- ),
583
- config.addExtensions,
584
- ),
585
- )
586
- }
587
-
588
- const routeImports = [
589
- ...config.routeTreeFileHeader,
590
- `// This file was automatically generated by TanStack Router.
591
- // You should NOT make any changes in this file as it will be overwritten.
592
- // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.`,
593
- [
594
- imports.length
595
- ? `import { ${imports.join(', ')} } from '${ROUTE_TEMPLATE.fullPkg}'`
596
- : '',
597
- !TYPES_DISABLED && typeImports.length
598
- ? `import type { ${typeImports.join(', ')} } from '${ROUTE_TEMPLATE.fullPkg}'`
599
- : '',
600
- ]
601
- .filter(Boolean)
602
- .join('\n'),
603
- '// Import Routes',
604
- [
605
- `import { Route as rootRoute } from './${getImportPath(rootRouteNode)}'`,
606
- ...sortedRouteNodes
607
- .filter((d) => !d.isVirtual)
608
- .map((node) => {
609
- return `import { Route as ${
610
- node.variableName
611
- }RouteImport } from './${getImportPath(node)}'`
612
- }),
613
- ].join('\n'),
614
- virtualRouteNodes.length ? '// Create Virtual Routes' : '',
615
- virtualRouteNodes
616
- .map((node) => {
617
- return `const ${
618
- node.variableName
619
- }RouteImport = createFileRoute('${node.routePath}')()`
620
- })
621
- .join('\n'),
622
- '// Create/Update Routes',
623
- sortedRouteNodes
624
- .map((node) => {
625
- const loaderNode = routePiecesByPath[node.routePath!]?.loader
626
- const componentNode = routePiecesByPath[node.routePath!]?.component
627
- const errorComponentNode =
628
- routePiecesByPath[node.routePath!]?.errorComponent
629
- const pendingComponentNode =
630
- routePiecesByPath[node.routePath!]?.pendingComponent
631
- const lazyComponentNode = routePiecesByPath[node.routePath!]?.lazy
632
-
633
- return [
634
- [
635
- `const ${node.variableName}Route = ${node.variableName}RouteImport.update({
636
- ${[
637
- `id: '${node.path}'`,
638
- !node.isNonPath ? `path: '${node.cleanedPath}'` : undefined,
639
- `getParentRoute: () => ${node.parent?.variableName ?? 'root'}Route`,
640
- ]
641
- .filter(Boolean)
642
- .join(',')}
643
- }${TYPES_DISABLED ? '' : 'as any'})`,
644
- loaderNode
645
- ? `.updateLoader({ loader: lazyFn(() => import('./${replaceBackslash(
646
- removeExt(
647
- path.relative(
648
- path.dirname(config.generatedRouteTree),
649
- path.resolve(config.routesDirectory, loaderNode.filePath),
650
- ),
651
- config.addExtensions,
652
- ),
653
- )}'), 'loader') })`
654
- : '',
655
- componentNode || errorComponentNode || pendingComponentNode
656
- ? `.update({
657
- ${(
658
- [
659
- ['component', componentNode],
660
- ['errorComponent', errorComponentNode],
661
- ['pendingComponent', pendingComponentNode],
662
- ] as const
663
- )
664
- .filter((d) => d[1])
665
- .map((d) => {
666
- return `${
667
- d[0]
668
- }: lazyRouteComponent(() => import('./${replaceBackslash(
669
- removeExt(
670
- path.relative(
671
- path.dirname(config.generatedRouteTree),
672
- path.resolve(config.routesDirectory, d[1]!.filePath),
673
- ),
674
- config.addExtensions,
675
- ),
676
- )}'), '${d[0]}')`
677
- })
678
- .join('\n,')}
679
- })`
680
- : '',
681
- lazyComponentNode
682
- ? `.lazy(() => import('./${replaceBackslash(
683
- removeExt(
684
- path.relative(
685
- path.dirname(config.generatedRouteTree),
686
- path.resolve(
687
- config.routesDirectory,
688
- lazyComponentNode.filePath,
689
- ),
690
- ),
691
- config.addExtensions,
692
- ),
693
- )}').then((d) => d.Route))`
694
- : '',
695
- ].join(''),
696
- ].join('\n\n')
697
- })
698
- .join('\n\n'),
699
- ...(TYPES_DISABLED
700
- ? []
701
- : [
702
- '// Populate the FileRoutesByPath interface',
703
- `declare module '${ROUTE_TEMPLATE.fullPkg}' {
704
- interface FileRoutesByPath {
705
- ${routeNodes
1257
+ export function buildFileRoutesByPathInterface(opts: {
1258
+ routeNodes: Array<RouteNode>
1259
+ module: string
1260
+ interfaceName: string
1261
+ exportName: string
1262
+ }): string {
1263
+ return `declare module '${opts.module}' {
1264
+ interface ${opts.interfaceName} {
1265
+ ${opts.routeNodes
706
1266
  .map((routeNode) => {
707
1267
  const filePathId = routeNode.routePath
1268
+ let preloaderRoute = ''
1269
+
1270
+ if (routeNode.exports?.includes(opts.exportName)) {
1271
+ preloaderRoute = `typeof ${routeNode.variableName}${opts.exportName}Import`
1272
+ } else {
1273
+ preloaderRoute = 'unknown'
1274
+ }
1275
+
1276
+ const parent = findParent(routeNode, opts.exportName)
708
1277
 
709
1278
  return `'${filePathId}': {
710
1279
  id: '${filePathId}'
711
1280
  path: '${inferPath(routeNode)}'
712
1281
  fullPath: '${inferFullPath(routeNode)}'
713
- preLoaderRoute: typeof ${routeNode.variableName}RouteImport
714
- parentRoute: typeof ${
715
- routeNode.isVirtualParentRequired
716
- ? `${routeNode.parent?.variableName}Route`
717
- : routeNode.parent?.variableName
718
- ? `${routeNode.parent.variableName}RouteImport`
719
- : 'rootRoute'
720
- }
1282
+ preLoaderRoute: ${preloaderRoute}
1283
+ parentRoute: typeof ${parent}
721
1284
  }`
722
1285
  })
723
1286
  .join('\n')}
724
1287
  }
725
- }`,
726
- ]),
727
- ...(TYPES_DISABLED
728
- ? []
729
- : config.verboseFileRoutes !== false
730
- ? []
731
- : [
732
- `// Add type-safety to the createFileRoute function across the route tree`,
733
- routeNodes
734
- .map((routeNode) => {
735
- function getModuleDeclaration(routeNode?: RouteNode) {
736
- if (!isRouteNodeValidForAugmentation(routeNode)) {
737
- return ''
738
- }
739
- return `declare module './${getImportPath(routeNode)}' {
740
- const ${routeNode._fsRouteType === 'lazy' ? 'createLazyFileRoute' : 'createFileRoute'}: ${
741
- routeNode._fsRouteType === 'lazy'
742
- ? `CreateLazyFileRoute<FileRoutesByPath['${routeNode.routePath}']['preLoaderRoute']>}`
743
- : `CreateFileRoute<
744
- '${routeNode.routePath}',
745
- FileRoutesByPath['${routeNode.routePath}']['parentRoute'],
746
- FileRoutesByPath['${routeNode.routePath}']['id'],
747
- FileRoutesByPath['${routeNode.routePath}']['path'],
748
- FileRoutesByPath['${routeNode.routePath}']['fullPath']
749
- >
750
- }`
751
- }`
752
- }
753
- return (
754
- getModuleDeclaration(routeNode) +
755
- getModuleDeclaration(
756
- routePiecesByPath[routeNode.routePath!]?.lazy,
757
- )
758
- )
759
- })
760
- .join('\n'),
761
- ]),
762
- '// Create and export the route tree',
763
- routeConfigChildrenText,
764
- ...(TYPES_DISABLED
765
- ? []
766
- : [
767
- `export interface FileRoutesByFullPath {
768
- ${[...createRouteNodesByFullPath(routeNodes).entries()].map(
769
- ([fullPath, routeNode]) => {
770
- return `'${fullPath}': typeof ${getResolvedRouteNodeVariableName(routeNode)}`
771
- },
772
- )}
773
- }`,
774
- `export interface FileRoutesByTo {
775
- ${[...createRouteNodesByTo(routeNodes).entries()].map(([to, routeNode]) => {
776
- return `'${to}': typeof ${getResolvedRouteNodeVariableName(routeNode)}`
777
- })}
778
- }`,
779
- `export interface FileRoutesById {
780
- '${rootRouteId}': typeof rootRoute,
781
- ${[...createRouteNodesById(routeNodes).entries()].map(([id, routeNode]) => {
782
- return `'${id}': typeof ${getResolvedRouteNodeVariableName(routeNode)}`
783
- })}
784
- }`,
785
- `export interface FileRouteTypes {
786
- fileRoutesByFullPath: FileRoutesByFullPath
787
- fullPaths: ${routeNodes.length > 0 ? [...createRouteNodesByFullPath(routeNodes).keys()].map((fullPath) => `'${fullPath}'`).join('|') : 'never'}
788
- fileRoutesByTo: FileRoutesByTo
789
- to: ${routeNodes.length > 0 ? [...createRouteNodesByTo(routeNodes).keys()].map((to) => `'${to}'`).join('|') : 'never'}
790
- id: ${[`'${rootRouteId}'`, ...[...createRouteNodesById(routeNodes).keys()].map((id) => `'${id}'`)].join('|')}
791
- fileRoutesById: FileRoutesById
792
- }`,
793
- `export interface RootRouteChildren {
794
- ${routeTree.map((child) => `${child.variableName}Route: typeof ${getResolvedRouteNodeVariableName(child)}`).join(',')}
795
- }`,
796
- ]),
797
- `const rootRouteChildren${TYPES_DISABLED ? '' : ': RootRouteChildren'} = {
798
- ${routeTree.map((child) => `${child.variableName}Route: ${getResolvedRouteNodeVariableName(child)}`).join(',')}
799
- }`,
800
- `export const routeTree = rootRoute._addFileChildren(rootRouteChildren)${TYPES_DISABLED ? '' : '._addFileTypes<FileRouteTypes>()'}`,
801
- ...config.routeTreeFileFooter,
802
- ]
803
- .filter(Boolean)
804
- .join('\n\n')
805
-
806
- const createRouteManifest = () => {
807
- const routesManifest = {
808
- [rootRouteId]: {
809
- filePath: rootRouteNode.filePath,
810
- children: routeTree.map((d) => d.routePath),
811
- },
812
- ...Object.fromEntries(
813
- routeNodes.map((d) => {
814
- const filePathId = d.routePath
815
-
816
- return [
817
- filePathId,
818
- {
819
- filePath: d.filePath,
820
- parent: d.parent?.routePath ? d.parent.routePath : undefined,
821
- children: d.children?.map((childRoute) => childRoute.routePath),
822
- },
823
- ]
824
- }),
825
- ),
826
- }
827
-
828
- return JSON.stringify(
829
- {
830
- routes: routesManifest,
831
- },
832
- null,
833
- 2,
834
- )
835
- }
836
-
837
- const includeManifest = ['react', 'solid']
838
- const routeConfigFileContent =
839
- config.disableManifestGeneration || !includeManifest.includes(config.target)
840
- ? routeImports
841
- : [
842
- routeImports,
843
- '\n',
844
- '/* ROUTE_MANIFEST_START',
845
- createRouteManifest(),
846
- 'ROUTE_MANIFEST_END */',
847
- ].join('\n')
848
-
849
- if (!checkLatest()) return
850
-
851
- const existingRouteTreeContent = await fsp
852
- .readFile(path.resolve(config.generatedRouteTree), 'utf-8')
853
- .catch((err) => {
854
- if (err.code === 'ENOENT') {
855
- return ''
856
- }
857
-
858
- throw err
859
- })
860
-
861
- if (!checkLatest()) return
862
-
863
- // Ensure the directory exists
864
- await fsp.mkdir(path.dirname(path.resolve(config.generatedRouteTree)), {
865
- recursive: true,
866
- })
867
-
868
- if (!checkLatest()) return
869
-
870
- // Write the route tree file, if it has changed
871
- const routeTreeWriteResult = await writeIfDifferent(
872
- path.resolve(config.generatedRouteTree),
873
- config.enableRouteTreeFormatting
874
- ? await format(existingRouteTreeContent, config)
875
- : existingRouteTreeContent,
876
- config.enableRouteTreeFormatting
877
- ? await format(routeConfigFileContent, config)
878
- : routeConfigFileContent,
879
- {
880
- beforeWrite: () => {
881
- logger.log(`🟡 Updating ${config.generatedRouteTree}`)
882
- },
883
- },
884
- )
885
- if (routeTreeWriteResult && !checkLatest()) {
886
- return
887
- }
888
-
889
- logger.log(
890
- `✅ Processed ${routeNodes.length === 1 ? 'route' : 'routes'} in ${
891
- Date.now() - start
892
- }ms`,
893
- )
894
- }
895
-
896
- // function removeTrailingUnderscores(s?: string) {
897
- // return s?.replaceAll(/(_$)/gi, '').replaceAll(/(_\/)/gi, '/')
898
- // }
899
-
900
- function removeGroups(s: string) {
901
- return s.replace(possiblyNestedRouteGroupPatternRegex, '')
902
- }
903
-
904
- /**
905
- * Checks if a given RouteNode is valid for augmenting it with typing based on conditions.
906
- * Also asserts that the RouteNode is defined.
907
- *
908
- * @param routeNode - The RouteNode to check.
909
- * @returns A boolean indicating whether the RouteNode is defined.
910
- */
911
- function isRouteNodeValidForAugmentation(
912
- routeNode?: RouteNode,
913
- ): routeNode is RouteNode {
914
- if (!routeNode || routeNode.isVirtual) {
915
- return false
916
- }
917
- return true
918
- }
919
-
920
- /**
921
- * The `node.path` is used as the `id` in the route definition.
922
- * This function checks if the given node has a parent and if so, it determines the correct path for the given node.
923
- * @param node - The node to determine the path for.
924
- * @returns The correct path for the given node.
925
- */
926
- function determineNodePath(node: RouteNode) {
927
- return (node.path = node.parent
928
- ? node.routePath?.replace(node.parent.routePath ?? '', '') || '/'
929
- : node.routePath)
930
- }
931
-
932
- /**
933
- * Removes the last segment from a given path. Segments are considered to be separated by a '/'.
934
- *
935
- * @param {string} routePath - The path from which to remove the last segment. Defaults to '/'.
936
- * @returns {string} The path with the last segment removed.
937
- * @example
938
- * removeLastSegmentFromPath('/workspace/_auth/foo') // '/workspace/_auth'
939
- */
940
- function removeLastSegmentFromPath(routePath: string = '/'): string {
941
- const segments = routePath.split('/')
942
- segments.pop() // Remove the last segment
943
- return segments.join('/')
944
- }
945
-
946
- /**
947
- * Removes all segments from a given path that start with an underscore ('_').
948
- *
949
- * @param {string} routePath - The path from which to remove segments. Defaults to '/'.
950
- * @returns {string} The path with all underscore-prefixed segments removed.
951
- * @example
952
- * removeLayoutSegments('/workspace/_auth/foo') // '/workspace/foo'
953
- */
954
- function removeLayoutSegments(routePath: string = '/'): string {
955
- const segments = routePath.split('/')
956
- const newSegments = segments.filter((segment) => !segment.startsWith('_'))
957
- return newSegments.join('/')
958
- }
959
-
960
- function hasParentRoute(
961
- routes: Array<RouteNode>,
962
- node: RouteNode,
963
- routePathToCheck: string | undefined,
964
- ): RouteNode | null {
965
- if (!routePathToCheck || routePathToCheck === '/') {
966
- return null
967
- }
968
-
969
- const sortedNodes = multiSortBy(routes, [
970
- (d) => d.routePath!.length * -1,
971
- (d) => d.variableName,
972
- ]).filter((d) => d.routePath !== `/${rootPathId}`)
973
-
974
- for (const route of sortedNodes) {
975
- if (route.routePath === '/') continue
976
-
977
- if (
978
- routePathToCheck.startsWith(`${route.routePath}/`) &&
979
- route.routePath !== routePathToCheck
980
- ) {
981
- return route
982
- }
983
- }
984
-
985
- const segments = routePathToCheck.split('/')
986
- segments.pop() // Remove the last segment
987
- const parentRoutePath = segments.join('/')
988
-
989
- return hasParentRoute(routes, node, parentRoutePath)
990
- }
991
-
992
- /**
993
- * Gets the final variable name for a route
994
- */
995
- const getResolvedRouteNodeVariableName = (routeNode: RouteNode): string => {
996
- return routeNode.children?.length
997
- ? `${routeNode.variableName}RouteWithChildren`
998
- : `${routeNode.variableName}Route`
999
- }
1000
-
1001
- /**
1002
- * Creates a map from fullPath to routeNode
1003
- */
1004
- const createRouteNodesByFullPath = (
1005
- routeNodes: Array<RouteNode>,
1006
- ): Map<string, RouteNode> => {
1007
- return new Map(
1008
- routeNodes.map((routeNode) => [inferFullPath(routeNode), routeNode]),
1009
- )
1010
- }
1011
-
1012
- /**
1013
- * Create a map from 'to' to a routeNode
1014
- */
1015
- const createRouteNodesByTo = (
1016
- routeNodes: Array<RouteNode>,
1017
- ): Map<string, RouteNode> => {
1018
- return new Map(
1019
- dedupeBranchesAndIndexRoutes(routeNodes).map((routeNode) => [
1020
- inferTo(routeNode),
1021
- routeNode,
1022
- ]),
1023
- )
1024
- }
1025
-
1026
- /**
1027
- * Create a map from 'id' to a routeNode
1028
- */
1029
- const createRouteNodesById = (
1030
- routeNodes: Array<RouteNode>,
1031
- ): Map<string, RouteNode> => {
1032
- return new Map(
1033
- routeNodes.map((routeNode) => {
1034
- const id = routeNode.routePath ?? ''
1035
- return [id, routeNode]
1036
- }),
1037
- )
1038
- }
1039
-
1040
- /**
1041
- * Infers the full path for use by TS
1042
- */
1043
- const inferFullPath = (routeNode: RouteNode): string => {
1044
- const fullPath = removeGroups(
1045
- removeUnderscores(removeLayoutSegments(routeNode.routePath)) ?? '',
1046
- )
1047
-
1048
- return routeNode.cleanedPath === '/' ? fullPath : fullPath.replace(/\/$/, '')
1049
- }
1050
-
1051
- /**
1052
- * Infers the path for use by TS
1053
- */
1054
- const inferPath = (routeNode: RouteNode): string => {
1055
- return routeNode.cleanedPath === '/'
1056
- ? routeNode.cleanedPath
1057
- : (routeNode.cleanedPath?.replace(/\/$/, '') ?? '')
1058
- }
1059
-
1060
- /**
1061
- * Infers to path
1062
- */
1063
- const inferTo = (routeNode: RouteNode): string => {
1064
- const fullPath = inferFullPath(routeNode)
1065
-
1066
- if (fullPath === '/') return fullPath
1067
-
1068
- return fullPath.replace(/\/$/, '')
1069
- }
1070
-
1071
- /**
1072
- * Dedupes branches and index routes
1073
- */
1074
- const dedupeBranchesAndIndexRoutes = (
1075
- routes: Array<RouteNode>,
1076
- ): Array<RouteNode> => {
1077
- return routes.filter((route) => {
1078
- if (route.children?.find((child) => child.cleanedPath === '/')) return false
1079
- return true
1080
- })
1081
- }
1082
-
1083
- function checkUnique<TElement>(routes: Array<TElement>, key: keyof TElement) {
1084
- // Check no two routes have the same `key`
1085
- // if they do, throw an error with the conflicting filePaths
1086
- const keys = routes.map((d) => d[key])
1087
- const uniqueKeys = new Set(keys)
1088
- if (keys.length !== uniqueKeys.size) {
1089
- const duplicateKeys = keys.filter((d, i) => keys.indexOf(d) !== i)
1090
- const conflictingFiles = routes.filter((d) =>
1091
- duplicateKeys.includes(d[key]),
1092
- )
1093
- return conflictingFiles
1094
- }
1095
- return undefined
1096
- }
1097
-
1098
- function checkRouteFullPathUniqueness(
1099
- _routes: Array<RouteNode>,
1100
- config: Config,
1101
- ) {
1102
- const routes = _routes.map((d) => {
1103
- const inferredFullPath = inferFullPath(d)
1104
- return { ...d, inferredFullPath }
1105
- })
1106
-
1107
- const conflictingFiles = checkUnique(routes, 'inferredFullPath')
1108
-
1109
- if (conflictingFiles !== undefined) {
1110
- const errorMessage = `Conflicting configuration paths were found for the following route${conflictingFiles.length > 1 ? 's' : ''}: ${conflictingFiles
1111
- .map((p) => `"${p.inferredFullPath}"`)
1112
- .join(', ')}.
1113
- Please ensure each Route has a unique full path.
1114
- Conflicting files: \n ${conflictingFiles.map((d) => path.resolve(config.routesDirectory, d.filePath)).join('\n ')}\n`
1115
- throw new Error(errorMessage)
1116
- }
1288
+ }`
1117
1289
  }