@benjavicente/router-generator 1.166.24

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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/_virtual/_rolldown/runtime.cjs +23 -0
  3. package/dist/cjs/config.cjs +134 -0
  4. package/dist/cjs/config.cjs.map +1 -0
  5. package/dist/cjs/config.d.cts +254 -0
  6. package/dist/cjs/filesystem/physical/getRouteNodes.cjs +234 -0
  7. package/dist/cjs/filesystem/physical/getRouteNodes.cjs.map +1 -0
  8. package/dist/cjs/filesystem/physical/getRouteNodes.d.cts +25 -0
  9. package/dist/cjs/filesystem/physical/rootPathId.cjs +6 -0
  10. package/dist/cjs/filesystem/physical/rootPathId.cjs.map +1 -0
  11. package/dist/cjs/filesystem/physical/rootPathId.d.cts +1 -0
  12. package/dist/cjs/filesystem/virtual/config.cjs +39 -0
  13. package/dist/cjs/filesystem/virtual/config.cjs.map +1 -0
  14. package/dist/cjs/filesystem/virtual/config.d.cts +3 -0
  15. package/dist/cjs/filesystem/virtual/getRouteNodes.cjs +175 -0
  16. package/dist/cjs/filesystem/virtual/getRouteNodes.cjs.map +1 -0
  17. package/dist/cjs/filesystem/virtual/getRouteNodes.d.cts +9 -0
  18. package/dist/cjs/filesystem/virtual/loadConfigFile.cjs +12 -0
  19. package/dist/cjs/filesystem/virtual/loadConfigFile.cjs.map +1 -0
  20. package/dist/cjs/filesystem/virtual/loadConfigFile.d.cts +1 -0
  21. package/dist/cjs/generator.cjs +805 -0
  22. package/dist/cjs/generator.cjs.map +1 -0
  23. package/dist/cjs/generator.d.cts +116 -0
  24. package/dist/cjs/index.cjs +33 -0
  25. package/dist/cjs/index.d.cts +12 -0
  26. package/dist/cjs/logger.cjs +31 -0
  27. package/dist/cjs/logger.cjs.map +1 -0
  28. package/dist/cjs/logger.d.cts +10 -0
  29. package/dist/cjs/plugin/types.d.cts +18 -0
  30. package/dist/cjs/template.cjs +203 -0
  31. package/dist/cjs/template.cjs.map +1 -0
  32. package/dist/cjs/template.d.cts +34 -0
  33. package/dist/cjs/transform/transform.cjs +302 -0
  34. package/dist/cjs/transform/transform.cjs.map +1 -0
  35. package/dist/cjs/transform/transform.d.cts +4 -0
  36. package/dist/cjs/transform/types.d.cts +31 -0
  37. package/dist/cjs/transform/utils.cjs +34 -0
  38. package/dist/cjs/transform/utils.cjs.map +1 -0
  39. package/dist/cjs/transform/utils.d.cts +2 -0
  40. package/dist/cjs/types.d.cts +57 -0
  41. package/dist/cjs/utils.cjs +653 -0
  42. package/dist/cjs/utils.cjs.map +1 -0
  43. package/dist/cjs/utils.d.cts +212 -0
  44. package/dist/cjs/validate-route-params.cjs +73 -0
  45. package/dist/cjs/validate-route-params.cjs.map +1 -0
  46. package/dist/cjs/validate-route-params.d.cts +9 -0
  47. package/dist/esm/config.d.ts +254 -0
  48. package/dist/esm/config.js +129 -0
  49. package/dist/esm/config.js.map +1 -0
  50. package/dist/esm/filesystem/physical/getRouteNodes.d.ts +25 -0
  51. package/dist/esm/filesystem/physical/getRouteNodes.js +230 -0
  52. package/dist/esm/filesystem/physical/getRouteNodes.js.map +1 -0
  53. package/dist/esm/filesystem/physical/rootPathId.d.ts +1 -0
  54. package/dist/esm/filesystem/physical/rootPathId.js +6 -0
  55. package/dist/esm/filesystem/physical/rootPathId.js.map +1 -0
  56. package/dist/esm/filesystem/virtual/config.d.ts +3 -0
  57. package/dist/esm/filesystem/virtual/config.js +38 -0
  58. package/dist/esm/filesystem/virtual/config.js.map +1 -0
  59. package/dist/esm/filesystem/virtual/getRouteNodes.d.ts +9 -0
  60. package/dist/esm/filesystem/virtual/getRouteNodes.js +173 -0
  61. package/dist/esm/filesystem/virtual/getRouteNodes.js.map +1 -0
  62. package/dist/esm/filesystem/virtual/loadConfigFile.d.ts +1 -0
  63. package/dist/esm/filesystem/virtual/loadConfigFile.js +11 -0
  64. package/dist/esm/filesystem/virtual/loadConfigFile.js.map +1 -0
  65. package/dist/esm/generator.d.ts +116 -0
  66. package/dist/esm/generator.js +801 -0
  67. package/dist/esm/generator.js.map +1 -0
  68. package/dist/esm/index.d.ts +12 -0
  69. package/dist/esm/index.js +8 -0
  70. package/dist/esm/logger.d.ts +10 -0
  71. package/dist/esm/logger.js +31 -0
  72. package/dist/esm/logger.js.map +1 -0
  73. package/dist/esm/plugin/types.d.ts +18 -0
  74. package/dist/esm/template.d.ts +34 -0
  75. package/dist/esm/template.js +202 -0
  76. package/dist/esm/template.js.map +1 -0
  77. package/dist/esm/transform/transform.d.ts +4 -0
  78. package/dist/esm/transform/transform.js +301 -0
  79. package/dist/esm/transform/transform.js.map +1 -0
  80. package/dist/esm/transform/types.d.ts +31 -0
  81. package/dist/esm/transform/utils.d.ts +2 -0
  82. package/dist/esm/transform/utils.js +34 -0
  83. package/dist/esm/transform/utils.js.map +1 -0
  84. package/dist/esm/types.d.ts +57 -0
  85. package/dist/esm/utils.d.ts +212 -0
  86. package/dist/esm/utils.js +609 -0
  87. package/dist/esm/utils.js.map +1 -0
  88. package/dist/esm/validate-route-params.d.ts +9 -0
  89. package/dist/esm/validate-route-params.js +73 -0
  90. package/dist/esm/validate-route-params.js.map +1 -0
  91. package/package.json +82 -0
  92. package/src/config.ts +247 -0
  93. package/src/filesystem/physical/getRouteNodes.ts +541 -0
  94. package/src/filesystem/physical/rootPathId.ts +1 -0
  95. package/src/filesystem/virtual/config.ts +45 -0
  96. package/src/filesystem/virtual/getRouteNodes.ts +307 -0
  97. package/src/filesystem/virtual/loadConfigFile.ts +8 -0
  98. package/src/generator.ts +1686 -0
  99. package/src/index.ts +54 -0
  100. package/src/logger.ts +43 -0
  101. package/src/plugin/types.ts +18 -0
  102. package/src/template.ts +313 -0
  103. package/src/transform/transform.ts +534 -0
  104. package/src/transform/types.ts +39 -0
  105. package/src/transform/utils.ts +42 -0
  106. package/src/types.ts +74 -0
  107. package/src/utils.ts +1067 -0
  108. package/src/validate-route-params.ts +118 -0
@@ -0,0 +1,541 @@
1
+ import path from 'node:path'
2
+ import * as fsp from 'node:fs/promises'
3
+ import {
4
+ determineInitialRoutePath,
5
+ escapeRegExp,
6
+ hasEscapedLeadingUnderscore,
7
+ removeExt,
8
+ replaceBackslash,
9
+ routePathToVariable,
10
+ unwrapBracketWrappedSegment,
11
+ } from '../../utils'
12
+ import { getRouteNodes as getRouteNodesVirtual } from '../virtual/getRouteNodes'
13
+ import { loadConfigFile } from '../virtual/loadConfigFile'
14
+ import { logging } from '../../logger'
15
+ import { rootPathId } from './rootPathId'
16
+ import type {
17
+ VirtualRootRoute,
18
+ VirtualRouteSubtreeConfig,
19
+ } from '@benjavicente/virtual-file-routes'
20
+ import type { FsRouteType, GetRouteNodesResult, RouteNode } from '../../types'
21
+ import type { Config } from '../../config'
22
+
23
+ /**
24
+ * Pre-compiled segment regexes for matching token patterns against route segments.
25
+ * These are created once (in Generator constructor) and passed through to avoid
26
+ * repeated regex compilation during route crawling.
27
+ */
28
+ export interface TokenRegexBundle {
29
+ indexTokenSegmentRegex: RegExp
30
+ routeTokenSegmentRegex: RegExp
31
+ }
32
+
33
+ const disallowedRouteGroupConfiguration = /\(([^)]+)\).(ts|js|tsx|jsx|vue)/
34
+
35
+ const virtualConfigFileRegExp = /__virtual\.[mc]?[jt]s$/
36
+ export function isVirtualConfigFile(fileName: string): boolean {
37
+ return virtualConfigFileRegExp.test(fileName)
38
+ }
39
+
40
+ export async function getRouteNodes(
41
+ config: Pick<
42
+ Config,
43
+ | 'routesDirectory'
44
+ | 'routeFilePrefix'
45
+ | 'routeFileIgnorePrefix'
46
+ | 'routeFileIgnorePattern'
47
+ | 'disableLogging'
48
+ | 'routeToken'
49
+ | 'indexToken'
50
+ >,
51
+ root: string,
52
+ tokenRegexes: TokenRegexBundle,
53
+ ): Promise<GetRouteNodesResult> {
54
+ const { routeFilePrefix, routeFileIgnorePrefix, routeFileIgnorePattern } =
55
+ config
56
+
57
+ const logger = logging({ disabled: config.disableLogging })
58
+ const routeFileIgnoreRegExp = new RegExp(routeFileIgnorePattern ?? '', 'g')
59
+
60
+ const routeNodes: Array<RouteNode> = []
61
+ const allPhysicalDirectories: Array<string> = []
62
+
63
+ async function recurse(dir: string) {
64
+ const fullDir = path.resolve(config.routesDirectory, dir)
65
+ let dirList = await fsp.readdir(fullDir, { withFileTypes: true })
66
+
67
+ dirList = dirList.filter((d) => {
68
+ if (
69
+ d.name.startsWith('.') ||
70
+ (routeFileIgnorePrefix && d.name.startsWith(routeFileIgnorePrefix))
71
+ ) {
72
+ return false
73
+ }
74
+
75
+ if (routeFilePrefix) {
76
+ if (routeFileIgnorePattern) {
77
+ return (
78
+ d.name.startsWith(routeFilePrefix) &&
79
+ !d.name.match(routeFileIgnoreRegExp)
80
+ )
81
+ }
82
+
83
+ return d.name.startsWith(routeFilePrefix)
84
+ }
85
+
86
+ if (routeFileIgnorePattern) {
87
+ return !d.name.match(routeFileIgnoreRegExp)
88
+ }
89
+
90
+ return true
91
+ })
92
+
93
+ const virtualConfigFile = dirList.find((dirent) => {
94
+ return dirent.isFile() && isVirtualConfigFile(dirent.name)
95
+ })
96
+
97
+ if (virtualConfigFile !== undefined) {
98
+ const virtualRouteConfigExport = await loadConfigFile(
99
+ path.resolve(fullDir, virtualConfigFile.name),
100
+ )
101
+ let virtualRouteSubtreeConfig: VirtualRouteSubtreeConfig
102
+ if (typeof virtualRouteConfigExport.default === 'function') {
103
+ virtualRouteSubtreeConfig = await virtualRouteConfigExport.default()
104
+ } else {
105
+ virtualRouteSubtreeConfig = virtualRouteConfigExport.default
106
+ }
107
+ const dummyRoot: VirtualRootRoute = {
108
+ type: 'root',
109
+ file: '',
110
+ children: virtualRouteSubtreeConfig,
111
+ }
112
+ const { routeNodes: virtualRouteNodes, physicalDirectories } =
113
+ await getRouteNodesVirtual(
114
+ {
115
+ ...config,
116
+ routesDirectory: fullDir,
117
+ virtualRouteConfig: dummyRoot,
118
+ },
119
+ root,
120
+ tokenRegexes,
121
+ )
122
+ allPhysicalDirectories.push(...physicalDirectories)
123
+ virtualRouteNodes.forEach((node) => {
124
+ const filePath = replaceBackslash(path.join(dir, node.filePath))
125
+ const routePath = `/${dir}${node.routePath}`
126
+
127
+ node.variableName = routePathToVariable(
128
+ `${dir}/${removeExt(node.filePath)}`,
129
+ )
130
+ node.routePath = routePath
131
+ // Keep originalRoutePath aligned with routePath for escape detection
132
+ if (node.originalRoutePath) {
133
+ node.originalRoutePath = `/${dir}${node.originalRoutePath}`
134
+ }
135
+ node.filePath = filePath
136
+ // Virtual subtree nodes (from __virtual.ts) are embedded in a
137
+ // physical directory tree. They should use path-based parent
138
+ // inference, not the explicit virtual parent tracking. Clear any
139
+ // _virtualParentRoutePath that was set at construction time.
140
+ delete node._virtualParentRoutePath
141
+ })
142
+
143
+ routeNodes.push(...virtualRouteNodes)
144
+
145
+ return
146
+ }
147
+
148
+ await Promise.all(
149
+ dirList.map(async (dirent) => {
150
+ const fullPath = replaceBackslash(path.join(fullDir, dirent.name))
151
+ const relativePath = path.posix.join(dir, dirent.name)
152
+
153
+ if (dirent.isDirectory()) {
154
+ await recurse(relativePath)
155
+ } else if (fullPath.match(/\.(tsx|ts|jsx|js|vue)$/)) {
156
+ const filePath = replaceBackslash(path.join(dir, dirent.name))
157
+ const filePathNoExt = removeExt(filePath)
158
+ const {
159
+ routePath: initialRoutePath,
160
+ originalRoutePath: initialOriginalRoutePath,
161
+ } = determineInitialRoutePath(filePathNoExt)
162
+
163
+ let routePath = initialRoutePath
164
+ let originalRoutePath = initialOriginalRoutePath
165
+
166
+ if (routeFilePrefix) {
167
+ routePath = routePath.replaceAll(routeFilePrefix, '')
168
+ originalRoutePath = originalRoutePath.replaceAll(
169
+ routeFilePrefix,
170
+ '',
171
+ )
172
+ }
173
+
174
+ if (disallowedRouteGroupConfiguration.test(dirent.name)) {
175
+ const errorMessage = `A route configuration for a route group was found at \`${filePath}\`. This is not supported. Did you mean to use a layout/pathless route instead?`
176
+ logger.error(`ERROR: ${errorMessage}`)
177
+ throw new Error(errorMessage)
178
+ }
179
+
180
+ const meta = getRouteMeta(routePath, originalRoutePath, tokenRegexes)
181
+ const variableName = meta.variableName
182
+ let routeType: FsRouteType = meta.fsRouteType
183
+
184
+ if (routeType === 'lazy') {
185
+ routePath = routePath.replace(/\/lazy$/, '')
186
+ originalRoutePath = originalRoutePath.replace(/\/lazy$/, '')
187
+ }
188
+
189
+ // this check needs to happen after the lazy route has been cleaned up
190
+ // since the routePath is used to determine if a route is pathless
191
+ if (
192
+ isValidPathlessLayoutRoute(
193
+ routePath,
194
+ originalRoutePath,
195
+ routeType,
196
+ tokenRegexes,
197
+ )
198
+ ) {
199
+ routeType = 'pathless_layout'
200
+ }
201
+
202
+ // Only show deprecation warning for .tsx/.ts files, not .vue files
203
+ // Vue files using .component.vue is the Vue-native way
204
+ const isVueFile = filePath.endsWith('.vue')
205
+ if (!isVueFile) {
206
+ ;(
207
+ [
208
+ ['component', 'component'],
209
+ ['errorComponent', 'errorComponent'],
210
+ ['notFoundComponent', 'notFoundComponent'],
211
+ ['pendingComponent', 'pendingComponent'],
212
+ ['loader', 'loader'],
213
+ ] satisfies Array<[FsRouteType, string]>
214
+ ).forEach(([matcher, type]) => {
215
+ if (routeType === matcher) {
216
+ logger.warn(
217
+ `WARNING: The \`.${type}.tsx\` suffix used for the ${filePath} file is deprecated. Use the new \`.lazy.tsx\` suffix instead.`,
218
+ )
219
+ }
220
+ })
221
+ }
222
+
223
+ // Get the last segment of originalRoutePath to check for escaping
224
+ const originalSegments = originalRoutePath.split('/').filter(Boolean)
225
+ const lastOriginalSegmentForSuffix =
226
+ originalSegments[originalSegments.length - 1] || ''
227
+
228
+ const { routeTokenSegmentRegex, indexTokenSegmentRegex } =
229
+ tokenRegexes
230
+
231
+ // List of special suffixes that can be escaped
232
+ const specialSuffixes = [
233
+ 'component',
234
+ 'errorComponent',
235
+ 'notFoundComponent',
236
+ 'pendingComponent',
237
+ 'loader',
238
+ 'lazy',
239
+ ]
240
+
241
+ const routePathSegments = routePath.split('/').filter(Boolean)
242
+ const lastRouteSegment =
243
+ routePathSegments[routePathSegments.length - 1] || ''
244
+
245
+ const suffixToStrip = specialSuffixes.find((suffix) => {
246
+ const endsWithSuffix = routePath.endsWith(`/${suffix}`)
247
+ // A suffix is escaped if wrapped in brackets in the original: [lazy] means literal "lazy"
248
+ const isEscaped =
249
+ lastOriginalSegmentForSuffix.startsWith('[') &&
250
+ lastOriginalSegmentForSuffix.endsWith(']') &&
251
+ unwrapBracketWrappedSegment(lastOriginalSegmentForSuffix) ===
252
+ suffix
253
+ return endsWithSuffix && !isEscaped
254
+ })
255
+
256
+ const routeTokenCandidate = unwrapBracketWrappedSegment(
257
+ lastOriginalSegmentForSuffix,
258
+ )
259
+ const isRouteTokenEscaped =
260
+ lastOriginalSegmentForSuffix !== routeTokenCandidate &&
261
+ routeTokenSegmentRegex.test(routeTokenCandidate)
262
+
263
+ const shouldStripRouteToken =
264
+ routeTokenSegmentRegex.test(lastRouteSegment) &&
265
+ !isRouteTokenEscaped
266
+
267
+ if (suffixToStrip || shouldStripRouteToken) {
268
+ const stripSegment = suffixToStrip ?? lastRouteSegment
269
+ routePath = routePath.replace(
270
+ new RegExp(`/${escapeRegExp(stripSegment)}$`),
271
+ '',
272
+ )
273
+ originalRoutePath = originalRoutePath.replace(
274
+ new RegExp(`/${escapeRegExp(stripSegment)}$`),
275
+ '',
276
+ )
277
+ }
278
+
279
+ // Check if the index token should be treated specially or as a literal path
280
+ // Escaping stays literal-only: if the last original segment is bracket-wrapped,
281
+ // treat it as literal even if it matches the token regex.
282
+ const lastOriginalSegment =
283
+ originalRoutePath.split('/').filter(Boolean).pop() || ''
284
+
285
+ const indexTokenCandidate =
286
+ unwrapBracketWrappedSegment(lastOriginalSegment)
287
+ const isIndexEscaped =
288
+ lastOriginalSegment !== indexTokenCandidate &&
289
+ indexTokenSegmentRegex.test(indexTokenCandidate)
290
+
291
+ if (!isIndexEscaped) {
292
+ const updatedRouteSegments = routePath.split('/').filter(Boolean)
293
+ const updatedLastRouteSegment =
294
+ updatedRouteSegments[updatedRouteSegments.length - 1] || ''
295
+
296
+ if (indexTokenSegmentRegex.test(updatedLastRouteSegment)) {
297
+ if (routePathSegments.length === 1) {
298
+ routePath = '/'
299
+ }
300
+
301
+ if (lastOriginalSegment === updatedLastRouteSegment) {
302
+ originalRoutePath = '/'
303
+ }
304
+
305
+ // For layout routes, don't use '/' fallback - an empty path means
306
+ // "layout for the parent path" which is important for physical() mounts
307
+ // where route.tsx at root should have empty path, not '/'
308
+ const isLayoutRoute = routeType === 'layout'
309
+
310
+ routePath =
311
+ routePath.replace(
312
+ new RegExp(`/${escapeRegExp(updatedLastRouteSegment)}$`),
313
+ '/',
314
+ ) || (isLayoutRoute ? '' : '/')
315
+
316
+ originalRoutePath =
317
+ originalRoutePath.replace(
318
+ new RegExp(`/${escapeRegExp(indexTokenCandidate)}$`),
319
+ '/',
320
+ ) || (isLayoutRoute ? '' : '/')
321
+ }
322
+ }
323
+
324
+ routeNodes.push({
325
+ filePath,
326
+ fullPath,
327
+ routePath,
328
+ variableName,
329
+ _fsRouteType: routeType,
330
+ originalRoutePath,
331
+ })
332
+ }
333
+ }),
334
+ )
335
+
336
+ return routeNodes
337
+ }
338
+
339
+ await recurse('./')
340
+
341
+ // Find the root route node - prefer the actual route file over component/loader files
342
+ const rootRouteNode =
343
+ routeNodes.find(
344
+ (d) =>
345
+ d.routePath === `/${rootPathId}` &&
346
+ ![
347
+ 'component',
348
+ 'errorComponent',
349
+ 'notFoundComponent',
350
+ 'pendingComponent',
351
+ 'loader',
352
+ 'lazy',
353
+ ].includes(d._fsRouteType),
354
+ ) ?? routeNodes.find((d) => d.routePath === `/${rootPathId}`)
355
+ if (rootRouteNode) {
356
+ rootRouteNode._fsRouteType = '__root'
357
+ rootRouteNode.variableName = 'root'
358
+ }
359
+
360
+ return {
361
+ rootRouteNode,
362
+ routeNodes,
363
+ physicalDirectories: allPhysicalDirectories,
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Determines the metadata for a given route path based on the provided configuration.
369
+ *
370
+ * @param routePath - The determined initial routePath (with brackets removed).
371
+ * @param originalRoutePath - The original route path (may contain brackets for escaped content).
372
+ * @param tokenRegexes - Pre-compiled token regexes for matching.
373
+ * @returns An object containing the type of the route and the variable name derived from the route path.
374
+ */
375
+ export function getRouteMeta(
376
+ routePath: string,
377
+ originalRoutePath: string,
378
+ tokenRegexes: TokenRegexBundle,
379
+ ): {
380
+ // `__root` is can be more easily determined by filtering down to routePath === /${rootPathId}
381
+ // `pathless` is needs to determined after `lazy` has been cleaned up from the routePath
382
+ fsRouteType: Extract<
383
+ FsRouteType,
384
+ | 'static'
385
+ | 'layout'
386
+ | 'api'
387
+ | 'lazy'
388
+ | 'loader'
389
+ | 'component'
390
+ | 'pendingComponent'
391
+ | 'errorComponent'
392
+ | 'notFoundComponent'
393
+ >
394
+ variableName: string
395
+ } {
396
+ let fsRouteType: FsRouteType = 'static'
397
+
398
+ // Get the last segment from the original path to check for escaping
399
+ const originalSegments = originalRoutePath.split('/').filter(Boolean)
400
+ const lastOriginalSegment =
401
+ originalSegments[originalSegments.length - 1] || ''
402
+
403
+ const { routeTokenSegmentRegex } = tokenRegexes
404
+
405
+ // Helper to check if a specific suffix is escaped (literal-only)
406
+ // A suffix is escaped if the original segment is wrapped in brackets: [lazy] means literal "lazy"
407
+ const isSuffixEscaped = (suffix: string): boolean => {
408
+ return (
409
+ lastOriginalSegment.startsWith('[') &&
410
+ lastOriginalSegment.endsWith(']') &&
411
+ unwrapBracketWrappedSegment(lastOriginalSegment) === suffix
412
+ )
413
+ }
414
+
415
+ const routeSegments = routePath.split('/').filter(Boolean)
416
+ const lastRouteSegment = routeSegments[routeSegments.length - 1] || ''
417
+
418
+ const routeTokenCandidate = unwrapBracketWrappedSegment(lastOriginalSegment)
419
+ const isRouteTokenEscaped =
420
+ lastOriginalSegment !== routeTokenCandidate &&
421
+ routeTokenSegmentRegex.test(routeTokenCandidate)
422
+
423
+ if (routeTokenSegmentRegex.test(lastRouteSegment) && !isRouteTokenEscaped) {
424
+ // layout routes, i.e `/foo/route.tsx` or `/foo/_layout/route.tsx`
425
+ fsRouteType = 'layout'
426
+ } else if (routePath.endsWith('/lazy') && !isSuffixEscaped('lazy')) {
427
+ // lazy routes, i.e. `/foo.lazy.tsx`
428
+ fsRouteType = 'lazy'
429
+ } else if (routePath.endsWith('/loader') && !isSuffixEscaped('loader')) {
430
+ // loader routes, i.e. `/foo.loader.tsx`
431
+ fsRouteType = 'loader'
432
+ } else if (
433
+ routePath.endsWith('/component') &&
434
+ !isSuffixEscaped('component')
435
+ ) {
436
+ // component routes, i.e. `/foo.component.tsx`
437
+ fsRouteType = 'component'
438
+ } else if (
439
+ routePath.endsWith('/pendingComponent') &&
440
+ !isSuffixEscaped('pendingComponent')
441
+ ) {
442
+ // pending component routes, i.e. `/foo.pendingComponent.tsx`
443
+ fsRouteType = 'pendingComponent'
444
+ } else if (
445
+ routePath.endsWith('/errorComponent') &&
446
+ !isSuffixEscaped('errorComponent')
447
+ ) {
448
+ // error component routes, i.e. `/foo.errorComponent.tsx`
449
+ fsRouteType = 'errorComponent'
450
+ } else if (
451
+ routePath.endsWith('/notFoundComponent') &&
452
+ !isSuffixEscaped('notFoundComponent')
453
+ ) {
454
+ // not found component routes, i.e. `/foo.notFoundComponent.tsx`
455
+ fsRouteType = 'notFoundComponent'
456
+ }
457
+
458
+ // Use originalRoutePath for variable name when any segment is fully
459
+ // bracket-wrapped (e.g. [index], [route], [_]auth) to avoid collisions
460
+ // with their non-escaped counterparts that get special token treatment
461
+ const hasFullyEscapedSegment = originalSegments.some(
462
+ (seg) =>
463
+ seg.startsWith('[') &&
464
+ seg.endsWith(']') &&
465
+ !seg.slice(1, -1).includes('[') &&
466
+ !seg.slice(1, -1).includes(']'),
467
+ )
468
+ const variableName = routePathToVariable(
469
+ hasFullyEscapedSegment ? originalRoutePath : routePath,
470
+ )
471
+
472
+ return { fsRouteType, variableName }
473
+ }
474
+
475
+ /**
476
+ * Used to validate if a route is a pathless layout route
477
+ * @param normalizedRoutePath Normalized route path, i.e `/foo/_layout/route.tsx` and `/foo._layout.route.tsx` to `/foo/_layout/route`
478
+ * @param originalRoutePath Original route path with brackets for escaped content
479
+ * @param routeType The route type determined from file extension
480
+ * @param tokenRegexes Pre-compiled token regexes for matching
481
+ * @returns Boolean indicating if the route is a pathless layout route
482
+ */
483
+ function isValidPathlessLayoutRoute(
484
+ normalizedRoutePath: string,
485
+ originalRoutePath: string,
486
+ routeType: FsRouteType,
487
+ tokenRegexes: TokenRegexBundle,
488
+ ): boolean {
489
+ if (routeType === 'lazy') {
490
+ return false
491
+ }
492
+
493
+ const segments = normalizedRoutePath.split('/').filter(Boolean)
494
+ const originalSegments = originalRoutePath.split('/').filter(Boolean)
495
+
496
+ if (segments.length === 0) {
497
+ return false
498
+ }
499
+
500
+ const lastRouteSegment = segments[segments.length - 1]!
501
+ const lastOriginalSegment =
502
+ originalSegments[originalSegments.length - 1] || ''
503
+ const secondToLastRouteSegment = segments[segments.length - 2]
504
+ const secondToLastOriginalSegment =
505
+ originalSegments[originalSegments.length - 2]
506
+
507
+ // If segment === __root, then exit as false
508
+ if (lastRouteSegment === rootPathId) {
509
+ return false
510
+ }
511
+
512
+ const { routeTokenSegmentRegex, indexTokenSegmentRegex } = tokenRegexes
513
+
514
+ // If segment matches routeToken and secondToLastSegment is a string that starts with _, then exit as true
515
+ // Since the route is actually a configuration route for a layout/pathless route
516
+ // i.e. /foo/_layout/route.tsx === /foo/_layout.tsx
517
+ // But if the underscore is escaped, it's not a pathless layout
518
+ if (
519
+ routeTokenSegmentRegex.test(lastRouteSegment) &&
520
+ typeof secondToLastRouteSegment === 'string' &&
521
+ typeof secondToLastOriginalSegment === 'string'
522
+ ) {
523
+ // Check if the underscore is escaped
524
+ if (hasEscapedLeadingUnderscore(secondToLastOriginalSegment)) {
525
+ return false
526
+ }
527
+ return secondToLastRouteSegment.startsWith('_')
528
+ }
529
+
530
+ // Segment starts with _ but check if it's escaped
531
+ // If the original segment has [_] at the start, the underscore is escaped and it's not a pathless layout
532
+ if (hasEscapedLeadingUnderscore(lastOriginalSegment)) {
533
+ return false
534
+ }
535
+
536
+ return (
537
+ !indexTokenSegmentRegex.test(lastRouteSegment) &&
538
+ !routeTokenSegmentRegex.test(lastRouteSegment) &&
539
+ lastRouteSegment.startsWith('_')
540
+ )
541
+ }
@@ -0,0 +1 @@
1
+ export const rootPathId = '__root'
@@ -0,0 +1,45 @@
1
+ import { z } from 'zod'
2
+ import type {
3
+ LayoutRoute,
4
+ PhysicalSubtree,
5
+ Route,
6
+ VirtualRootRoute,
7
+ } from '@benjavicente/virtual-file-routes'
8
+
9
+ const indexRouteSchema = z.object({
10
+ type: z.literal('index'),
11
+ file: z.string(),
12
+ })
13
+
14
+ const layoutRouteSchema: z.ZodType<LayoutRoute> = z.object({
15
+ type: z.literal('layout'),
16
+ id: z.string().optional(),
17
+ file: z.string(),
18
+ children: z.array(z.lazy(() => virtualRouteNodeSchema)).optional(),
19
+ })
20
+
21
+ const routeSchema: z.ZodType<Route> = z.object({
22
+ type: z.literal('route'),
23
+ file: z.string().optional(),
24
+ path: z.string(),
25
+ children: z.array(z.lazy(() => virtualRouteNodeSchema)).optional(),
26
+ })
27
+
28
+ const physicalSubTreeSchema: z.ZodType<PhysicalSubtree> = z.object({
29
+ type: z.literal('physical'),
30
+ directory: z.string(),
31
+ pathPrefix: z.string(),
32
+ })
33
+
34
+ const virtualRouteNodeSchema = z.union([
35
+ indexRouteSchema,
36
+ layoutRouteSchema,
37
+ routeSchema,
38
+ physicalSubTreeSchema,
39
+ ])
40
+
41
+ export const virtualRootRouteSchema: z.ZodType<VirtualRootRoute> = z.object({
42
+ type: z.literal('root'),
43
+ file: z.string(),
44
+ children: z.array(virtualRouteNodeSchema).optional(),
45
+ })