@tanstack/router-generator 1.121.0-alpha.5 → 1.121.1

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