@tanstack/router-cli 0.0.1-beta.29

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.
@@ -0,0 +1,509 @@
1
+ import klaw from 'klaw'
2
+ import through2 from 'through2'
3
+ import path from 'path'
4
+ 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'
13
+ import { Config } from './config'
14
+
15
+ let latestTask = 0
16
+ export const rootRouteName = '__root'
17
+ export const rootRouteClientName = '__root.client'
18
+
19
+ export type RouteNode = {
20
+ filename: string
21
+ clientFilename: string
22
+ fileNameNoExt: string
23
+ 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
38
+ isRoot?: boolean
39
+ children?: RouteNode[]
40
+ }
41
+
42
+ export type IsolatedExport = {
43
+ key: string
44
+ exported: boolean
45
+ code?: string | null
46
+ }
47
+
48
+ let nodeCache: RouteNode[] = undefined!
49
+
50
+ export async function generator(config: Config) {
51
+ console.log()
52
+
53
+ let first = false
54
+
55
+ if (!nodeCache) {
56
+ first = true
57
+ console.log('🔄 Generating routes...')
58
+ nodeCache = []
59
+ } else {
60
+ console.log('♻️ Regenerating routes...')
61
+ }
62
+
63
+ const taskId = latestTask + 1
64
+ latestTask = taskId
65
+
66
+ const checkLatest = () => {
67
+ if (latestTask !== taskId) {
68
+ console.log(`- Skipping since file changes were made while generating.`)
69
+ return false
70
+ }
71
+
72
+ return true
73
+ }
74
+
75
+ 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
97
+ }
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)
176
+
177
+ async function buildRouteConfig(
178
+ nodes: RouteNode[],
179
+ depth = 1,
180
+ ): 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
+
199
+ const routeCode = await fs.readFile(node.fullPath, 'utf-8')
200
+
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
+ }
279
+ }
280
+
281
+ routeConfigImports.push(
282
+ `import { routeConfig as ${node.variable}Route } from './${removeExt(
283
+ path.relative(config.routeGenDirectory, node.genPath),
284
+ )}'`,
285
+ )
286
+
287
+ routeConfigClientImports.push(
288
+ `import { routeConfig as ${node.variable}Route } from './${removeExt(
289
+ path.relative(
290
+ config.routeGenDirectory,
291
+ path.resolve(node.genDir, node.clientFilename),
292
+ ),
293
+ )}'`,
294
+ )
295
+
296
+ if (node.isRoot) {
297
+ return undefined
298
+ }
299
+
300
+ const route = `${node.variable}Route`
301
+
302
+ if (node.children?.length) {
303
+ const childConfigs = await buildRouteConfig(node.children, depth + 1)
304
+ return `${route}.addChildren([\n${spaces(
305
+ depth * 4,
306
+ )}${childConfigs}\n${spaces(depth * 2)}])`
307
+ }
308
+
309
+ return route
310
+ })
311
+
312
+ return (await Promise.all(children))
313
+ .filter(Boolean)
314
+ .join(`,\n${spaces(depth * 2)}`)
315
+ }
316
+
317
+ const routeConfigChildrenText = await buildRouteConfig(reparented)
318
+
319
+ routeConfigImports = multiSortBy(routeConfigImports, [
320
+ (d) => (d.includes('__root') ? -1 : 1),
321
+ (d) => d.split('/').length,
322
+ (d) => (d.endsWith("index'") ? -1 : 1),
323
+ (d) => d,
324
+ ])
325
+
326
+ routeConfigClientImports = multiSortBy(routeConfigClientImports, [
327
+ (d) => (d.includes('__root') ? -1 : 1),
328
+ (d) => d.split('/').length,
329
+ (d) => (d.endsWith("index.client'") ? -1 : 1),
330
+ (d) => d,
331
+ ])
332
+
333
+ const routeConfig = `export const routeConfig = rootRoute.addChildren([\n ${routeConfigChildrenText}\n])\nexport type __GeneratedRouteConfig = typeof routeConfig`
334
+ const routeConfigClient = `export const routeConfigClient = rootRoute.addChildren([\n ${routeConfigChildrenText}\n]) as __GeneratedRouteConfig`
335
+
336
+ const routeConfigFileContent = [
337
+ routeConfigImports.join('\n'),
338
+ routeConfig,
339
+ ].join('\n\n')
340
+
341
+ const routeConfigClientFileContent = [
342
+ `import type { __GeneratedRouteConfig } from './routeConfig'`,
343
+ routeConfigClientImports.join('\n'),
344
+ routeConfigClient,
345
+ ].join('\n\n')
346
+
347
+ if (nodesChanged) {
348
+ queueWriteFile(
349
+ path.resolve(config.routeGenDirectory, 'routeConfig.ts'),
350
+ routeConfigFileContent,
351
+ )
352
+ queueWriteFile(
353
+ path.resolve(config.routeGenDirectory, 'routeConfig.client.ts'),
354
+ routeConfigClientFileContent,
355
+ )
356
+ }
357
+
358
+ // Do all of our file system manipulation at the end
359
+ await fs.mkdir(config.routeGenDirectory, { recursive: true })
360
+
361
+ if (!checkLatest()) return
362
+
363
+ await Promise.all(
364
+ fileQueue.map(async ([filename, content]) => {
365
+ await fs.ensureDir(path.dirname(filename))
366
+ const exists = await fs.pathExists(filename)
367
+ let current = ''
368
+ if (exists) {
369
+ current = await fs.readFile(filename, 'utf-8')
370
+ }
371
+ if (current !== content) {
372
+ await fs.writeFile(filename, content)
373
+ }
374
+ }),
375
+ )
376
+
377
+ if (!checkLatest()) return
378
+
379
+ const allFiles = await getAllFiles(config.routeGenDirectory)
380
+
381
+ if (!checkLatest()) return
382
+
383
+ const removedNodes: RouteNode[] = []
384
+
385
+ nodeCache = nodeCache.filter((d) => {
386
+ if (d.version !== latestTask) {
387
+ removedNodes.push(d)
388
+ return false
389
+ }
390
+ return true
391
+ })
392
+
393
+ const newNodes = nodeCache.filter((d) => d.new)
394
+ const updatedNodes = nodeCache.filter((d) => !d.new && d.changed)
395
+
396
+ const unusedFiles = allFiles.filter((d) => {
397
+ if (
398
+ d === path.resolve(config.routeGenDirectory, 'routeConfig.ts') ||
399
+ d === path.resolve(config.routeGenDirectory, 'routeConfig.client.ts')
400
+ ) {
401
+ return false
402
+ }
403
+
404
+ let node = nodeCache.find(
405
+ (n) =>
406
+ n.genPath === d ||
407
+ path.resolve(n.genDir, n.clientFilename) === d ||
408
+ n.importedFiles?.includes(d),
409
+ )
410
+
411
+ return !node
412
+ })
413
+
414
+ await Promise.all(
415
+ unusedFiles.map((d) => {
416
+ fs.remove(d)
417
+ }),
418
+ )
419
+
420
+ console.log(
421
+ `🌲 Processed ${nodeCache.length} routes in ${Date.now() - start}ms`,
422
+ )
423
+
424
+ if (newNodes.length || updatedNodes.length || removedNodes.length) {
425
+ if (newNodes.length) {
426
+ console.log(`🥳 Added ${newNodes.length} new routes`)
427
+ }
428
+
429
+ if (updatedNodes.length) {
430
+ console.log(`✅ Updated ${updatedNodes.length} routes`)
431
+ }
432
+
433
+ if (removedNodes.length) {
434
+ console.log(`🗑 Removed ${removedNodes.length} unused routes`)
435
+ }
436
+ } else {
437
+ console.log(`🎉 No changes were found. Carry on!`)
438
+ }
439
+ }
440
+
441
+ function getAllFiles(dir: string): Promise<string[]> {
442
+ return new Promise((resolve, reject) => {
443
+ const excludeDirFilter = through2.obj(function (item, enc, next) {
444
+ if (!item.stats.isDirectory()) this.push(item)
445
+ next()
446
+ })
447
+
448
+ const items: string[] = []
449
+
450
+ klaw(dir)
451
+ .pipe(excludeDirFilter)
452
+ .on('data', (item) => items.push(item.path))
453
+ .on('error', (err) => reject(err))
454
+ .on('end', () => resolve(items))
455
+ })
456
+ }
457
+
458
+ function fileToVariable(d: string) {
459
+ return d
460
+ .split('/')
461
+ .map((d, i) => (i > 0 ? capitalize(d) : d))
462
+ .join('')
463
+ .replace(/([^a-zA-Z0-9]|[\.])/gm, '')
464
+ }
465
+
466
+ export function removeExt(d: string) {
467
+ return d.substring(0, d.lastIndexOf('.')) || d
468
+ }
469
+
470
+ function spaces(d: number): string {
471
+ return Array.from({ length: d })
472
+ .map(() => ' ')
473
+ .join('')
474
+ }
475
+
476
+ export function multiSortBy<T>(
477
+ arr: T[],
478
+ accessors: ((item: T) => any)[] = [(d) => d],
479
+ ): T[] {
480
+ return arr
481
+ .map((d, i) => [d, i] as const)
482
+ .sort(([a, ai], [b, bi]) => {
483
+ for (const accessor of accessors) {
484
+ const ao = accessor(a)
485
+ const bo = accessor(b)
486
+
487
+ if (typeof ao === 'undefined') {
488
+ if (typeof bo === 'undefined') {
489
+ continue
490
+ }
491
+ return 1
492
+ }
493
+
494
+ if (ao === bo) {
495
+ continue
496
+ }
497
+
498
+ return ao > bo ? 1 : -1
499
+ }
500
+
501
+ return ai - bi
502
+ })
503
+ .map(([d]) => d)
504
+ }
505
+
506
+ function capitalize(s: string) {
507
+ if (typeof s !== 'string') return ''
508
+ return s.charAt(0).toUpperCase() + s.slice(1)
509
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ import * as yargs from 'yargs'
2
+ import { getConfig } from './config'
3
+ import { generate } from './generate'
4
+ import { watch } from './watch'
5
+
6
+ main()
7
+
8
+ export function main() {
9
+ yargs
10
+ .scriptName('tsr')
11
+ .usage('$0 <cmd> [args]')
12
+ .command('generate', 'Generate the routes for a project', async (argv) => {
13
+ const config = await getConfig()
14
+ await generate(config)
15
+ })
16
+ .command(
17
+ 'watch',
18
+ 'Continuously watch and generate the routes for a project',
19
+ async (argv) => {
20
+ watch()
21
+ },
22
+ )
23
+ .help().argv
24
+ }