@tanstack/router-generator 1.147.1 → 1.149.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/cjs/config.cjs +29 -6
  2. package/dist/cjs/config.cjs.map +1 -1
  3. package/dist/cjs/config.d.cts +94 -12
  4. package/dist/cjs/filesystem/physical/getRouteNodes.cjs +50 -27
  5. package/dist/cjs/filesystem/physical/getRouteNodes.cjs.map +1 -1
  6. package/dist/cjs/filesystem/physical/getRouteNodes.d.cts +12 -3
  7. package/dist/cjs/filesystem/virtual/getRouteNodes.cjs +12 -5
  8. package/dist/cjs/filesystem/virtual/getRouteNodes.cjs.map +1 -1
  9. package/dist/cjs/filesystem/virtual/getRouteNodes.d.cts +3 -2
  10. package/dist/cjs/generator.cjs +32 -7
  11. package/dist/cjs/generator.cjs.map +1 -1
  12. package/dist/cjs/generator.d.cts +20 -2
  13. package/dist/cjs/utils.cjs +47 -0
  14. package/dist/cjs/utils.cjs.map +1 -1
  15. package/dist/cjs/utils.d.cts +6 -1
  16. package/dist/esm/config.d.ts +94 -12
  17. package/dist/esm/config.js +29 -6
  18. package/dist/esm/config.js.map +1 -1
  19. package/dist/esm/filesystem/physical/getRouteNodes.d.ts +12 -3
  20. package/dist/esm/filesystem/physical/getRouteNodes.js +51 -28
  21. package/dist/esm/filesystem/physical/getRouteNodes.js.map +1 -1
  22. package/dist/esm/filesystem/virtual/getRouteNodes.d.ts +3 -2
  23. package/dist/esm/filesystem/virtual/getRouteNodes.js +12 -5
  24. package/dist/esm/filesystem/virtual/getRouteNodes.js.map +1 -1
  25. package/dist/esm/generator.d.ts +20 -2
  26. package/dist/esm/generator.js +33 -8
  27. package/dist/esm/generator.js.map +1 -1
  28. package/dist/esm/utils.d.ts +6 -1
  29. package/dist/esm/utils.js +47 -0
  30. package/dist/esm/utils.js.map +1 -1
  31. package/package.json +5 -5
  32. package/src/config.ts +58 -6
  33. package/src/filesystem/physical/getRouteNodes.ts +105 -43
  34. package/src/filesystem/virtual/getRouteNodes.ts +12 -2
  35. package/src/generator.ts +59 -9
  36. package/src/utils.ts +73 -1
@@ -17,6 +17,7 @@ import type {
17
17
  } from '@tanstack/virtual-file-routes'
18
18
  import type { GetRouteNodesResult, RouteNode } from '../../types'
19
19
  import type { Config } from '../../config'
20
+ import type { TokenRegexBundle } from '../physical/getRouteNodes'
20
21
 
21
22
  function ensureLeadingUnderScore(id: string) {
22
23
  if (id.startsWith('_')) {
@@ -49,6 +50,7 @@ export async function getRouteNodes(
49
50
  | 'routeToken'
50
51
  >,
51
52
  root: string,
53
+ tokenRegexes: TokenRegexBundle,
52
54
  ): Promise<GetRouteNodesResult> {
53
55
  const fullDir = resolve(tsrConfig.routesDirectory)
54
56
  if (tsrConfig.virtualRouteConfig === undefined) {
@@ -68,6 +70,7 @@ export async function getRouteNodes(
68
70
  root,
69
71
  fullDir,
70
72
  virtualRouteConfig.children,
73
+ tokenRegexes,
71
74
  )
72
75
  const allNodes = flattenTree({
73
76
  children,
@@ -134,7 +137,8 @@ export async function getRouteNodesRecursive(
134
137
  >,
135
138
  root: string,
136
139
  fullDir: string,
137
- nodes?: Array<VirtualRouteNode>,
140
+ nodes: Array<VirtualRouteNode> | undefined,
141
+ tokenRegexes: TokenRegexBundle,
138
142
  parent?: RouteNode,
139
143
  ): Promise<{ children: Array<RouteNode>; physicalDirectories: Array<string> }> {
140
144
  if (nodes === undefined) {
@@ -150,8 +154,12 @@ export async function getRouteNodesRecursive(
150
154
  routesDirectory: resolve(fullDir, node.directory),
151
155
  },
152
156
  root,
157
+ tokenRegexes,
158
+ )
159
+ allPhysicalDirectories.push(
160
+ resolve(fullDir, node.directory),
161
+ ...physicalDirectories,
153
162
  )
154
- allPhysicalDirectories.push(node.directory)
155
163
  routeNodes.forEach((subtreeNode) => {
156
164
  subtreeNode.variableName = routePathToVariable(
157
165
  `${node.pathPrefix}/${removeExt(subtreeNode.filePath)}`,
@@ -229,6 +237,7 @@ export async function getRouteNodesRecursive(
229
237
  root,
230
238
  fullDir,
231
239
  node.children,
240
+ tokenRegexes,
232
241
  routeNode,
233
242
  )
234
243
  routeNode.children = children
@@ -275,6 +284,7 @@ export async function getRouteNodesRecursive(
275
284
  root,
276
285
  fullDir,
277
286
  node.children,
287
+ tokenRegexes,
278
288
  routeNode,
279
289
  )
280
290
  routeNode.children = children
package/src/generator.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  createRouteNodesByFullPath,
21
21
  createRouteNodesById,
22
22
  createRouteNodesByTo,
23
+ createTokenRegex,
23
24
  determineNodePath,
24
25
  findParent,
25
26
  format,
@@ -191,8 +192,26 @@ export class Generator {
191
192
  private static routeGroupPatternRegex = /\(.+\)/
192
193
  private physicalDirectories: Array<string> = []
193
194
 
194
- private indexTokenRegex: RegExp
195
- private routeTokenRegex: RegExp
195
+ /**
196
+ * Token regexes are pre-compiled once here and reused throughout route processing.
197
+ * We need TWO types of regex for each token because they match against different inputs:
198
+ *
199
+ * 1. FILENAME regexes: Match token patterns within full file path strings.
200
+ * Example: For file "routes/dashboard.index.tsx", we want to detect ".index."
201
+ * Pattern: `[./](?:token)[.]` - matches token bounded by path separators/dots
202
+ * Used in: sorting route nodes by file path
203
+ *
204
+ * 2. SEGMENT regexes: Match token against a single logical route segment.
205
+ * Example: For segment "index" (extracted from path), match the whole segment
206
+ * Pattern: `^(?:token)$` - matches entire segment exactly
207
+ * Used in: route parsing, determining route types, escape detection
208
+ *
209
+ * We cannot reuse one for the other without false positives or missing matches.
210
+ */
211
+ private indexTokenFilenameRegex: RegExp
212
+ private routeTokenFilenameRegex: RegExp
213
+ private indexTokenSegmentRegex: RegExp
214
+ private routeTokenSegmentRegex: RegExp
196
215
  private static componentPieceRegex =
197
216
  /[./](component|errorComponent|notFoundComponent|pendingComponent|loader|lazy)[.]/
198
217
 
@@ -207,8 +226,19 @@ export class Generator {
207
226
  this.routesDirectoryPath = this.getRoutesDirectoryPath()
208
227
  this.plugins.push(...(opts.config.plugins || []))
209
228
 
210
- this.indexTokenRegex = new RegExp(`[./]${this.config.indexToken}[.]`)
211
- this.routeTokenRegex = new RegExp(`[./]${this.config.routeToken}[.]`)
229
+ // Create all token regexes once in constructor
230
+ this.indexTokenFilenameRegex = createTokenRegex(this.config.indexToken, {
231
+ type: 'filename',
232
+ })
233
+ this.routeTokenFilenameRegex = createTokenRegex(this.config.routeToken, {
234
+ type: 'filename',
235
+ })
236
+ this.indexTokenSegmentRegex = createTokenRegex(this.config.indexToken, {
237
+ type: 'segment',
238
+ })
239
+ this.routeTokenSegmentRegex = createTokenRegex(this.config.routeToken, {
240
+ type: 'segment',
241
+ })
212
242
 
213
243
  for (const plugin of this.plugins) {
214
244
  plugin.init?.({ generator: this })
@@ -330,9 +360,19 @@ export class Generator {
330
360
  let getRouteNodesResult: GetRouteNodesResult
331
361
 
332
362
  if (this.config.virtualRouteConfig) {
333
- getRouteNodesResult = await virtualGetRouteNodes(this.config, this.root)
363
+ getRouteNodesResult = await virtualGetRouteNodes(this.config, this.root, {
364
+ indexTokenSegmentRegex: this.indexTokenSegmentRegex,
365
+ routeTokenSegmentRegex: this.routeTokenSegmentRegex,
366
+ })
334
367
  } else {
335
- getRouteNodesResult = await physicalGetRouteNodes(this.config, this.root)
368
+ getRouteNodesResult = await physicalGetRouteNodes(
369
+ this.config,
370
+ this.root,
371
+ {
372
+ indexTokenSegmentRegex: this.indexTokenSegmentRegex,
373
+ routeTokenSegmentRegex: this.routeTokenSegmentRegex,
374
+ },
375
+ )
336
376
  }
337
377
 
338
378
  const {
@@ -354,9 +394,9 @@ export class Generator {
354
394
  const preRouteNodes = multiSortBy(beforeRouteNodes, [
355
395
  (d) => (d.routePath === '/' ? -1 : 1),
356
396
  (d) => d.routePath?.split('/').length,
357
- (d) => (d.filePath.match(this.indexTokenRegex) ? 1 : -1),
397
+ (d) => (d.filePath.match(this.indexTokenFilenameRegex) ? 1 : -1),
358
398
  (d) => (d.filePath.match(Generator.componentPieceRegex) ? 1 : -1),
359
- (d) => (d.filePath.match(this.routeTokenRegex) ? -1 : 1),
399
+ (d) => (d.filePath.match(this.routeTokenFilenameRegex) ? -1 : 1),
360
400
  (d) => (d.routePath?.endsWith('/') ? -1 : 1),
361
401
  (d) => d.routePath,
362
402
  ]).filter((d) => {
@@ -541,10 +581,20 @@ export class Generator {
541
581
 
542
582
  const { rootRouteNode, acc } = opts
543
583
 
584
+ // Use pre-compiled regex if config hasn't been overridden, otherwise create new one
585
+ const indexTokenSegmentRegex =
586
+ config.indexToken === this.config.indexToken
587
+ ? this.indexTokenSegmentRegex
588
+ : createTokenRegex(config.indexToken, { type: 'segment' })
589
+
544
590
  const sortedRouteNodes = multiSortBy(acc.routeNodes, [
545
591
  (d) => (d.routePath?.includes(`/${rootPathId}`) ? -1 : 1),
546
592
  (d) => d.routePath?.split('/').length,
547
- (d) => (d.routePath?.endsWith(config.indexToken) ? -1 : 1),
593
+ (d) => {
594
+ const segments = d.routePath?.split('/').filter(Boolean) ?? []
595
+ const last = segments[segments.length - 1] ?? ''
596
+ return indexTokenSegmentRegex.test(last) ? -1 : 1
597
+ },
548
598
  (d) => d,
549
599
  ])
550
600
 
package/src/utils.ts CHANGED
@@ -3,7 +3,7 @@ import * as fsp from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import * as prettier from 'prettier'
5
5
  import { rootPathId } from './filesystem/physical/rootPathId'
6
- import type { Config } from './config'
6
+ import type { Config, TokenMatcher } from './config'
7
7
  import type { ImportDeclaration, RouteNode } from './types'
8
8
 
9
9
  /**
@@ -418,6 +418,78 @@ function escapeRegExp(s: string): string {
418
418
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
419
419
  }
420
420
 
421
+ function sanitizeTokenFlags(flags?: string): string | undefined {
422
+ if (!flags) return flags
423
+
424
+ // Prevent stateful behavior with RegExp.prototype.test/exec
425
+ // g = global, y = sticky
426
+ return flags.replace(/[gy]/g, '')
427
+ }
428
+
429
+ export function createTokenRegex(
430
+ token: TokenMatcher,
431
+ opts: {
432
+ type: 'segment' | 'filename'
433
+ },
434
+ ): RegExp {
435
+ // Defensive check: if token is undefined/null, throw a clear error
436
+ // (runtime safety for config loading edge cases)
437
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
438
+ if (token === undefined || token === null) {
439
+ throw new Error(
440
+ `createTokenRegex: token is ${token}. This usually means the config was not properly parsed with defaults.`,
441
+ )
442
+ }
443
+
444
+ try {
445
+ if (typeof token === 'string') {
446
+ return opts.type === 'segment'
447
+ ? new RegExp(`^${escapeRegExp(token)}$`)
448
+ : new RegExp(`[./]${escapeRegExp(token)}[.]`)
449
+ }
450
+
451
+ if (token instanceof RegExp) {
452
+ const flags = sanitizeTokenFlags(token.flags)
453
+ return opts.type === 'segment'
454
+ ? new RegExp(`^(?:${token.source})$`, flags)
455
+ : new RegExp(`[./](?:${token.source})[.]`, flags)
456
+ }
457
+
458
+ // Handle JSON regex object form: { regex: string, flags?: string }
459
+ if (typeof token === 'object' && 'regex' in token) {
460
+ const flags = sanitizeTokenFlags(token.flags)
461
+ return opts.type === 'segment'
462
+ ? new RegExp(`^(?:${token.regex})$`, flags)
463
+ : new RegExp(`[./](?:${token.regex})[.]`, flags)
464
+ }
465
+
466
+ throw new Error(
467
+ `createTokenRegex: invalid token type. Expected string, RegExp, or { regex, flags } object, got: ${typeof token}`,
468
+ )
469
+ } catch (e) {
470
+ if (e instanceof SyntaxError) {
471
+ const pattern =
472
+ typeof token === 'string'
473
+ ? token
474
+ : token instanceof RegExp
475
+ ? token.source
476
+ : token.regex
477
+ throw new Error(
478
+ `Invalid regex pattern in token config: "${pattern}". ${e.message}`,
479
+ )
480
+ }
481
+ throw e
482
+ }
483
+ }
484
+
485
+ export function isBracketWrappedSegment(segment: string): boolean {
486
+ return segment.startsWith('[') && segment.endsWith(']')
487
+ }
488
+
489
+ export function unwrapBracketWrappedSegment(segment: string): string {
490
+ return isBracketWrappedSegment(segment) ? segment.slice(1, -1) : segment
491
+ }
492
+
421
493
  export function removeLeadingUnderscores(s: string, routeToken: string) {
422
494
  if (!s) return s
423
495