@tanstack/router-cli 0.0.1-beta.69 → 1.0.0

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.
package/src/generator.ts CHANGED
@@ -1,61 +1,105 @@
1
- import klaw from 'klaw'
2
- import through2 from 'through2'
3
1
  import path from 'path'
4
2
  import fs from 'fs-extra'
5
- import crypto from 'crypto'
6
- import {
7
- detectExports,
8
- ensureBoilerplate,
9
- generateRouteConfig,
10
- isolatedProperties,
11
- isolateOptionToExport,
12
- } from './transformCode'
3
+ import * as prettier from 'prettier'
13
4
  import { Config } from './config'
5
+ import { cleanPath, trimPathLeft } from '@tanstack/react-router'
14
6
 
15
7
  let latestTask = 0
16
- export const rootRouteName = '__root'
17
- export const rootRouteClientName = '__root.client'
8
+ export const rootPathId = '__root'
9
+ export const fileRouteRegex = /new\s+FileRoute\(([^)]*)\)/g
18
10
 
19
11
  export type RouteNode = {
20
- filename: string
21
- clientFilename: string
22
- fileNameNoExt: string
12
+ filePath: string
23
13
  fullPath: string
24
- fullDir: string
25
- isDirectory: boolean
26
- isIndex: boolean
27
- variable: string
28
- childRoutesDir?: string
29
- genPath: string
30
- genDir: string
31
- genPathNoExt: string
32
- parent?: RouteNode
33
- hash?: string
34
- importedFiles?: string[]
35
- version?: number
36
- changed?: boolean
37
- new?: boolean
14
+ variableName: string
15
+ routePath?: string
16
+ cleanedPath?: string
17
+ path?: string
18
+ isNonPath?: boolean
19
+ isNonLayout?: boolean
38
20
  isRoot?: boolean
39
21
  children?: RouteNode[]
22
+ parent?: RouteNode
40
23
  }
41
24
 
42
- export type IsolatedExport = {
43
- key: string
44
- exported: boolean
45
- code?: string | null
25
+ async function getRouteNodes(config: Config) {
26
+ const { routeFilePrefix, routeFileIgnorePrefix } = config
27
+
28
+ let routeNodes: RouteNode[] = []
29
+
30
+ async function recurse(dir: string) {
31
+ const fullDir = path.resolve(config.routesDirectory, dir)
32
+ let dirList = await fs.readdir(fullDir)
33
+
34
+ dirList = dirList.filter((d) => {
35
+ if (
36
+ d.startsWith('.') ||
37
+ (routeFileIgnorePrefix && d.startsWith(routeFileIgnorePrefix))
38
+ ) {
39
+ return false
40
+ }
41
+
42
+ if (routeFilePrefix) {
43
+ return d.startsWith(routeFilePrefix)
44
+ }
45
+
46
+ return true
47
+ })
48
+
49
+ await Promise.all(
50
+ dirList.map(async (fileName) => {
51
+ const fullPath = path.join(fullDir, fileName)
52
+ const relativePath = path.join(dir, fileName)
53
+ const stat = await fs.stat(fullPath)
54
+
55
+ if (stat.isDirectory()) {
56
+ await recurse(relativePath)
57
+ } else {
58
+ const filePath = path.join(dir, fileName)
59
+ const filePathNoExt = removeExt(filePath)
60
+ let routePath =
61
+ replaceBackslash(
62
+ cleanPath(`/${filePathNoExt.split('.').join('/')}`),
63
+ ) ?? ''
64
+ const variableName = fileToVariable(routePath)
65
+
66
+ // Remove the index from the route path and
67
+ // if the route path is empty, use `/'
68
+ if (routePath === 'index') {
69
+ routePath = '/'
70
+ } else if (routePath.endsWith('/index')) {
71
+ routePath = routePath.replace(/\/index$/, '/')
72
+ }
73
+
74
+ routeNodes.push({
75
+ filePath,
76
+ fullPath,
77
+ routePath,
78
+ variableName,
79
+ })
80
+ }
81
+ }),
82
+ )
83
+
84
+ return routeNodes
85
+ }
86
+
87
+ await recurse('./')
88
+
89
+ return routeNodes
46
90
  }
47
91
 
48
- let nodeCache: RouteNode[] = undefined!
92
+ let first = false
93
+ let skipMessage = false
49
94
 
50
95
  export async function generator(config: Config) {
51
96
  console.log()
52
97
 
53
- let first = false
54
-
55
- if (!nodeCache) {
56
- first = true
98
+ if (!first) {
57
99
  console.log('🔄 Generating routes...')
58
- nodeCache = []
100
+ first = true
101
+ } else if (skipMessage) {
102
+ skipMessage = false
59
103
  } else {
60
104
  console.log('♻️ Regenerating routes...')
61
105
  }
@@ -65,7 +109,7 @@ export async function generator(config: Config) {
65
109
 
66
110
  const checkLatest = () => {
67
111
  if (latestTask !== taskId) {
68
- console.log(`- Skipping since file changes were made while generating.`)
112
+ skipMessage = true
69
113
  return false
70
114
  }
71
115
 
@@ -73,398 +117,208 @@ export async function generator(config: Config) {
73
117
  }
74
118
 
75
119
  const start = Date.now()
76
- let routeConfigImports: string[] = []
77
- let routeConfigClientImports: string[] = []
78
-
79
- let nodesChanged = false
80
- const fileQueue: [string, string][] = []
81
- const queueWriteFile = (filename: string, content: string) => {
82
- fileQueue.push([filename, content])
83
- }
84
-
85
- async function reparent(dir: string): Promise<RouteNode[]> {
86
- let dirList
87
-
88
- try {
89
- dirList = await fs.readdir(dir)
90
- } catch (err) {
91
- console.log()
92
- console.error(
93
- 'TSR: Error reading the config.routesDirectory. Does it exist?',
94
- )
95
- console.log()
96
- throw err
120
+ const routePathIdPrefix = config.routeFilePrefix ?? ''
121
+
122
+ let routeNodes = await getRouteNodes(config)
123
+
124
+ routeNodes = multiSortBy(routeNodes, [
125
+ (d) => (d.routePath === '/' ? -1 : 1),
126
+ (d) => d.routePath?.split('/').length,
127
+ (d) => (d.routePath?.endsWith('/') ? -1 : 1),
128
+ (d) => d.routePath,
129
+ ]).filter((d) => d.routePath !== `/${routePathIdPrefix + rootPathId}`)
130
+
131
+ const routeTree: RouteNode[] = []
132
+
133
+ // Loop over the flat list of routeNodes and
134
+ // build up a tree based on the routeNodes' routePath
135
+ routeNodes.forEach((node) => {
136
+ // routeNodes.forEach((existingNode) => {
137
+ // if (
138
+ // node.routePath?.startsWith(`${existingNode?.routePath ?? ''}/`)
139
+ // // node.routePath.length > existingNode.routePath!.length
140
+ // ) {
141
+ // node.parent = existingNode
142
+ // }
143
+ // })
144
+ const parentRoute = hasParentRoute(routeNodes, node.routePath)
145
+ if (parentRoute) node.parent = parentRoute
146
+
147
+ node.path = node.parent
148
+ ? node.routePath?.replace(node.parent.routePath!, '') || '/'
149
+ : node.routePath
150
+
151
+ const trimmedPath = trimPathLeft(node.path ?? '')
152
+
153
+ const split = trimmedPath?.split('/') ?? []
154
+ let first = split[0] ?? trimmedPath ?? ''
155
+
156
+ node.isNonPath = first.startsWith('_')
157
+ node.isNonLayout = first.endsWith('_')
158
+
159
+ node.cleanedPath = removeUnderscores(node.path) ?? ''
160
+
161
+ if (node.parent) {
162
+ node.parent.children = node.parent.children ?? []
163
+ node.parent.children.push(node)
164
+ } else {
165
+ routeTree.push(node)
97
166
  }
98
-
99
- const dirListCombo = multiSortBy(
100
- await Promise.all(
101
- dirList.map(async (filename): Promise<RouteNode> => {
102
- const fullPath = path.resolve(dir, filename)
103
- const stat = await fs.lstat(fullPath)
104
- const ext = path.extname(filename)
105
-
106
- const clientFilename = filename.replace(ext, `.client${ext}`)
107
-
108
- const pathFromRoutes = path.relative(config.routesDirectory, fullPath)
109
- const genPath = path.resolve(config.routeGenDirectory, pathFromRoutes)
110
-
111
- const genPathNoExt = removeExt(genPath)
112
- const genDir = path.resolve(genPath, '..')
113
-
114
- const fileNameNoExt = removeExt(filename)
115
-
116
- return {
117
- filename,
118
- clientFilename,
119
- fileNameNoExt,
120
- fullPath,
121
- fullDir: dir,
122
- genPath,
123
- genDir,
124
- genPathNoExt,
125
- variable: fileToVariable(removeExt(pathFromRoutes)),
126
- isDirectory: stat.isDirectory(),
127
- isIndex: fileNameNoExt === 'index',
128
- }
129
- }),
130
- ),
131
- [
132
- (d) => (d.fileNameNoExt === 'index' ? -1 : 1),
133
- (d) => d.fileNameNoExt,
134
- (d) => (d.isDirectory ? 1 : -1),
135
- ],
136
- )
137
-
138
- const reparented: typeof dirListCombo = []
139
-
140
- dirListCombo.forEach(async (d, i) => {
141
- if (d.isDirectory) {
142
- const parent = reparented.find(
143
- (dd) => !dd.isDirectory && dd.fileNameNoExt === d.filename,
144
- )
145
-
146
- if (parent) {
147
- parent.childRoutesDir = d.fullPath
148
- } else {
149
- reparented.push(d)
150
- }
151
- } else {
152
- reparented.push(d)
153
- }
154
- })
155
-
156
- return Promise.all(
157
- reparented.map(async (d) => {
158
- if (d.childRoutesDir) {
159
- const children = await reparent(d.childRoutesDir)
160
-
161
- d = {
162
- ...d,
163
- children,
164
- }
165
-
166
- children.forEach((child) => (child.parent = d))
167
-
168
- return d
169
- }
170
- return d
171
- }),
172
- )
173
- }
174
-
175
- const reparented = await reparent(config.routesDirectory)
167
+ })
176
168
 
177
169
  async function buildRouteConfig(
178
170
  nodes: RouteNode[],
179
171
  depth = 1,
180
172
  ): Promise<string> {
181
- const children = nodes.map(async (n) => {
182
- let node = nodeCache.find((d) => d.fullPath === n.fullPath)!
183
-
184
- if (node) {
185
- node.new = false
186
- } else {
187
- node = n
188
- nodeCache.push(node)
189
- if (!first) {
190
- node.new = true
191
- }
192
- }
193
-
194
- node.version = latestTask
195
- if (node.fileNameNoExt === '__root') {
196
- node.isRoot = true
197
- }
198
-
173
+ const children = nodes.map(async (node) => {
199
174
  const routeCode = await fs.readFile(node.fullPath, 'utf-8')
200
175
 
201
- const hashSum = crypto.createHash('sha256')
202
- hashSum.update(routeCode)
203
- const hash = hashSum.digest('hex')
204
-
205
- node.changed = node.hash !== hash
206
- if (node.changed) {
207
- nodesChanged = true
208
- node.hash = hash
209
-
210
- try {
211
- // Ensure the boilerplate for the route exists
212
- const code = await ensureBoilerplate(node, routeCode)
213
-
214
- if (code) {
215
- await fs.writeFile(node.fullPath, code)
216
- }
217
-
218
- let imports: IsolatedExport[] = []
219
-
220
- if (!node.isRoot) {
221
- // Generate the isolated files
222
- const transforms = await Promise.all(
223
- isolatedProperties.map(async (key): Promise<IsolatedExport> => {
224
- let exported = false
225
- let exports: string[] = []
226
-
227
- const transformed = await isolateOptionToExport(
228
- node,
229
- routeCode,
230
- {
231
- isolate: key,
232
- },
233
- )
234
-
235
- if (transformed) {
236
- exports = await detectExports(transformed)
237
- if (exports.includes(key)) {
238
- exported = true
239
- }
240
- }
241
-
242
- return { key, exported, code: transformed }
243
- }),
244
- )
245
-
246
- imports = transforms.filter(({ exported }) => exported)
247
-
248
- node.importedFiles = await Promise.all(
249
- imports.map(({ key, code }) => {
250
- const importFilename = `${node.genPathNoExt}-${key}.tsx`
251
- queueWriteFile(importFilename, code!)
252
- return importFilename
253
- }),
254
- )
255
- }
256
-
257
- const routeConfigCode = await generateRouteConfig(
258
- node,
259
- routeCode,
260
- imports,
261
- false,
262
- )
263
-
264
- const clientRouteConfigCode = await generateRouteConfig(
265
- node,
266
- routeCode,
267
- imports,
268
- true,
269
- )
270
-
271
- queueWriteFile(node.genPath, routeConfigCode)
272
- queueWriteFile(
273
- path.resolve(node.genDir, node.clientFilename),
274
- clientRouteConfigCode,
275
- )
276
- } catch (err) {
277
- node.hash = ''
278
- }
176
+ // Ensure the boilerplate for the route exists
177
+ if (node.isRoot) {
178
+ return
279
179
  }
280
180
 
281
- routeConfigImports.push(
282
- `import { route as ${node.variable}Route } from './${removeExt(
283
- path
284
- .relative(config.routeGenDirectory, node.genPath)
285
- .replace(/\\/gi, '/'),
286
- )}'`,
181
+ // Ensure that new FileRoute(anything?) is replace with FileRoute(${node.routePath})
182
+ // routePath can contain $ characters, which have special meaning when used in replace
183
+ // so we have to escape it by turning all $ into $$. But since we do it through a replace call
184
+ // we have to double escape it into $$$$. For more information, see
185
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement
186
+ const escapedRoutePath = node.routePath?.replaceAll('$', '$$$$') ?? ''
187
+ const replaced = routeCode.replace(
188
+ fileRouteRegex,
189
+ `new FileRoute('${escapedRoutePath}')`,
287
190
  )
288
191
 
289
- routeConfigClientImports.push(
290
- `import { route as ${node.variable}Route } from './${removeExt(
291
- path
292
- .relative(
293
- config.routeGenDirectory,
294
- path.resolve(node.genDir, node.clientFilename),
295
- )
296
- .replace(/\\/gi, '/'),
297
- )}'`,
298
- )
299
-
300
- if (node.isRoot) {
301
- return undefined
192
+ if (replaced !== routeCode) {
193
+ await fs.writeFile(node.fullPath, replaced)
302
194
  }
303
195
 
304
- const route = `${node.variable}Route`
196
+ const route = `${node.variableName}Route`
305
197
 
306
198
  if (node.children?.length) {
307
199
  const childConfigs = await buildRouteConfig(node.children, depth + 1)
308
- return `${route}.addChildren([\n${spaces(
309
- depth * 4,
310
- )}${childConfigs}\n${spaces(depth * 2)}])`
200
+ return `${route}.addChildren([${spaces(depth * 4)}${childConfigs}])`
311
201
  }
312
202
 
313
203
  return route
314
204
  })
315
205
 
316
- return (await Promise.all(children))
317
- .filter(Boolean)
318
- .join(`,\n${spaces(depth * 2)}`)
206
+ return (await Promise.all(children)).filter(Boolean).join(`,`)
319
207
  }
320
208
 
321
- const routeConfigChildrenText = await buildRouteConfig(reparented)
322
-
323
- routeConfigImports = multiSortBy(routeConfigImports, [
324
- (d) => (d.includes('__root') ? -1 : 1),
325
- (d) => d.split('/').length,
326
- (d) => (d.endsWith("index'") ? -1 : 1),
327
- (d) => d,
328
- ])
329
-
330
- routeConfigClientImports = multiSortBy(routeConfigClientImports, [
331
- (d) => (d.includes('__root') ? -1 : 1),
332
- (d) => d.split('/').length,
333
- (d) => (d.endsWith("index.client'") ? -1 : 1),
334
- (d) => d,
335
- ])
336
-
337
- const routeConfig = `export const routeTree = rootRoute.addChildren([\n ${routeConfigChildrenText}\n])\nexport type __GeneratedRouteConfig = typeof routeTree`
338
- const routeConfigClient = `export const routeTreeClient = rootRoute.addChildren([\n ${routeConfigChildrenText}\n]) as __GeneratedRouteConfig`
339
-
340
- const routeConfigFileContent = [
341
- routeConfigImports.join('\n'),
342
- routeConfig,
343
- ].join('\n\n')
344
-
345
- const routeConfigClientFileContent = [
346
- `import type { __GeneratedRouteConfig } from './routeTree'`,
347
- routeConfigClientImports.join('\n'),
348
- routeConfigClient,
349
- ].join('\n\n')
350
-
351
- if (nodesChanged) {
352
- queueWriteFile(
353
- path.resolve(config.routeGenDirectory, 'routeTree.ts'),
354
- routeConfigFileContent,
355
- )
356
- queueWriteFile(
357
- path.resolve(config.routeGenDirectory, 'routeTree.client.ts'),
358
- routeConfigClientFileContent,
359
- )
360
- }
209
+ const routeConfigChildrenText = await buildRouteConfig(routeTree)
361
210
 
362
- // Do all of our file system manipulation at the end
363
- await fs.mkdir(config.routeGenDirectory, { recursive: true })
211
+ const routeImports = [
212
+ `import { Route as rootRoute } from './${sanitize(
213
+ path.relative(
214
+ path.dirname(config.generatedRouteTree),
215
+ path.resolve(config.routesDirectory, routePathIdPrefix + rootPathId),
216
+ ),
217
+ )}'`,
218
+ ...multiSortBy(routeNodes, [
219
+ (d) =>
220
+ d.routePath?.includes(`/${routePathIdPrefix + rootPathId}`) ? -1 : 1,
221
+ (d) => d.routePath?.split('/').length,
222
+ (d) => (d.routePath?.endsWith("index'") ? -1 : 1),
223
+ (d) => d,
224
+ ]).map((node) => {
225
+ return `import { Route as ${node.variableName}Route } from './${sanitize(
226
+ removeExt(
227
+ path.relative(
228
+ path.dirname(config.generatedRouteTree),
229
+ path.resolve(config.routesDirectory, node.filePath),
230
+ ),
231
+ ),
232
+ )}'`
233
+ }),
234
+ ].join('\n')
235
+
236
+ const routeTypes = `declare module '@tanstack/react-router' {
237
+ interface FileRoutesByPath {
238
+ ${routeNodes
239
+ .map((routeNode) => {
240
+ return `'${routeNode.routePath}': {
241
+ parentRoute: typeof ${routeNode.parent?.variableName ?? 'root'}Route
242
+ }`
243
+ })
244
+ .join('\n')}
245
+ }
246
+ }`
247
+
248
+ const routeOptions = routeNodes
249
+ .map((routeNode) => {
250
+ return `Object.assign(${routeNode.variableName ?? 'root'}Route.options, {
251
+ ${[
252
+ routeNode.isNonPath
253
+ ? `id: '${routeNode.cleanedPath}'`
254
+ : `path: '${routeNode.cleanedPath}'`,
255
+ `getParentRoute: () => ${
256
+ routeNode.parent?.variableName ?? 'root'
257
+ }Route`,
258
+ // `\n// ${JSON.stringify(
259
+ // {
260
+ // ...routeNode,
261
+ // parent: undefined,
262
+ // children: undefined,
263
+ // fullPath: undefined,
264
+ // variableName: undefined,
265
+ // },
266
+ // null,
267
+ // 2,
268
+ // )
269
+ // .split('\n')
270
+ // .join('\n// ')}`,
271
+ ]
272
+ .filter(Boolean)
273
+ .join(',')}
274
+ })`
275
+ })
276
+ .join('\n\n')
364
277
 
365
- if (!checkLatest()) return
278
+ const routeConfig = `export const routeTree = rootRoute.addChildren([${routeConfigChildrenText}])`
366
279
 
367
- await Promise.all(
368
- fileQueue.map(async ([filename, content]) => {
369
- await fs.ensureDir(path.dirname(filename))
370
- const exists = await fs.pathExists(filename)
371
- let current = ''
372
- if (exists) {
373
- current = await fs.readFile(filename, 'utf-8')
374
- }
375
- if (current !== content) {
376
- await fs.writeFile(filename, content)
377
- }
378
- }),
280
+ const routeConfigFileContent = await prettier.format(
281
+ [routeImports, routeTypes, routeOptions, routeConfig].join('\n\n'),
282
+ {
283
+ semi: false,
284
+ parser: 'typescript',
285
+ },
379
286
  )
380
287
 
381
- if (!checkLatest()) return
382
-
383
- const allFiles = await getAllFiles(config.routeGenDirectory)
288
+ const routeTreeContent = await fs
289
+ .readFile(path.resolve(config.generatedRouteTree), 'utf-8')
290
+ .catch((err: any) => {
291
+ if (err.code === 'ENOENT') {
292
+ return undefined
293
+ }
294
+ throw err
295
+ })
384
296
 
385
297
  if (!checkLatest()) return
386
298
 
387
- const removedNodes: RouteNode[] = []
388
-
389
- nodeCache = nodeCache.filter((d) => {
390
- if (d.version !== latestTask) {
391
- removedNodes.push(d)
392
- return false
393
- }
394
- return true
395
- })
396
-
397
- const newNodes = nodeCache.filter((d) => d.new)
398
- const updatedNodes = nodeCache.filter((d) => !d.new && d.changed)
399
-
400
- const unusedFiles = allFiles.filter((d) => {
401
- if (
402
- d === path.resolve(config.routeGenDirectory, 'routeTree.ts') ||
403
- d === path.resolve(config.routeGenDirectory, 'routeTree.client.ts')
404
- ) {
405
- return false
406
- }
407
-
408
- let node = nodeCache.find(
409
- (n) =>
410
- n.genPath === d ||
411
- path.resolve(n.genDir, n.clientFilename) === d ||
412
- n.importedFiles?.includes(d),
299
+ if (routeTreeContent !== routeConfigFileContent) {
300
+ await fs.ensureDir(path.dirname(path.resolve(config.generatedRouteTree)))
301
+ if (!checkLatest()) return
302
+ await fs.writeFile(
303
+ path.resolve(config.generatedRouteTree),
304
+ routeConfigFileContent,
413
305
  )
414
-
415
- return !node
416
- })
417
-
418
- await Promise.all(
419
- unusedFiles.map((d) => {
420
- fs.remove(d)
421
- }),
422
- )
306
+ }
423
307
 
424
308
  console.log(
425
- `🌲 Processed ${nodeCache.length} routes in ${Date.now() - start}ms`,
309
+ `🌲 Processed ${routeNodes.length} routes in ${Date.now() - start}ms`,
426
310
  )
427
-
428
- if (newNodes.length || updatedNodes.length || removedNodes.length) {
429
- if (newNodes.length) {
430
- console.log(`🥳 Added ${newNodes.length} new routes`)
431
- }
432
-
433
- if (updatedNodes.length) {
434
- console.log(`✅ Updated ${updatedNodes.length} routes`)
435
- }
436
-
437
- if (removedNodes.length) {
438
- console.log(`🗑 Removed ${removedNodes.length} unused routes`)
439
- }
440
- } else {
441
- console.log(`🎉 No changes were found. Carry on!`)
442
- }
443
311
  }
444
312
 
445
- function getAllFiles(dir: string): Promise<string[]> {
446
- return new Promise((resolve, reject) => {
447
- const excludeDirFilter = through2.obj(function (item, enc, next) {
448
- if (!item.stats.isDirectory()) this.push(item)
449
- next()
450
- })
451
-
452
- const items: string[] = []
453
-
454
- klaw(dir)
455
- .pipe(excludeDirFilter)
456
- .on('data', (item) => items.push(item.path))
457
- .on('error', (err) => reject(err))
458
- .on('end', () => resolve(items))
459
- })
460
- }
461
-
462
- function fileToVariable(d: string) {
463
- return d
464
- .split('/')
465
- .map((d, i) => (i > 0 ? capitalize(d) : d))
466
- .join('')
467
- .replace(/([^a-zA-Z0-9]|[\.])/gm, '')
313
+ function fileToVariable(d: string): string {
314
+ return (
315
+ removeUnderscores(d)
316
+ ?.replace(/\$/g, '')
317
+ ?.split(/[/-]/g)
318
+ .map((d, i) => (i > 0 ? capitalize(d) : d))
319
+ .join('')
320
+ .replace(/([^a-zA-Z0-9]|[\.])/gm, '') ?? ''
321
+ )
468
322
  }
469
323
 
470
324
  export function removeExt(d: string) {
@@ -511,3 +365,45 @@ function capitalize(s: string) {
511
365
  if (typeof s !== 'string') return ''
512
366
  return s.charAt(0).toUpperCase() + s.slice(1)
513
367
  }
368
+
369
+ function sanitize(s?: string) {
370
+ return replaceBackslash(s?.replace(/\\index/gi, ''))
371
+ }
372
+
373
+ function removeUnderscores(s?: string) {
374
+ return s?.replace(/(^_|_$)/, '').replace(/(\/_|_\/)/, '/')
375
+ }
376
+
377
+ function replaceBackslash(s?: string) {
378
+ return s?.replace(/\\/gi, '/')
379
+ }
380
+
381
+ export function hasParentRoute(
382
+ routes: RouteNode[],
383
+ routeToCheck: string | undefined,
384
+ ): RouteNode | null {
385
+ if (!routeToCheck || routeToCheck === '/') {
386
+ return null
387
+ }
388
+
389
+ const sortedNodes = multiSortBy(routes, [
390
+ (d) => d.routePath!.length * -1,
391
+ (d) => d.variableName,
392
+ ]).filter((d) => d.routePath !== `/${rootPathId}`)
393
+
394
+ for (const route of sortedNodes) {
395
+ if (route.routePath === '/') continue
396
+
397
+ if (
398
+ routeToCheck.startsWith(`${route.routePath}/`) &&
399
+ route.routePath !== routeToCheck
400
+ ) {
401
+ return route
402
+ }
403
+ }
404
+ const segments = routeToCheck.split('/')
405
+ segments.pop() // Remove the last segment
406
+ const parentRoute = segments.join('/')
407
+
408
+ return hasParentRoute(routes, parentRoute)
409
+ }