tokenlean 0.1.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.
@@ -0,0 +1,627 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * tl-routes - Extract routes from web frameworks
5
+ *
6
+ * Scans source files for route definitions in common web frameworks
7
+ * (Next.js, React Router, Vue Router, Express, etc.) and shows the
8
+ * route structure.
9
+ *
10
+ * Usage: tl-routes [dir] [--tree]
11
+ */
12
+
13
+ // Prompt info for tl-prompt
14
+ if (process.argv.includes('--prompt')) {
15
+ console.log(JSON.stringify({
16
+ name: 'tl-routes',
17
+ desc: 'Extract routes from web frameworks',
18
+ when: 'before-read',
19
+ example: 'tl-routes src/'
20
+ }));
21
+ process.exit(0);
22
+ }
23
+
24
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
25
+ import { join, relative, extname, dirname, basename } from 'path';
26
+ import {
27
+ createOutput,
28
+ parseCommonArgs,
29
+ COMMON_OPTIONS_HELP
30
+ } from '../src/output.mjs';
31
+ import { findProjectRoot, shouldSkip } from '../src/project.mjs';
32
+
33
+ const HELP = `
34
+ tl-routes - Extract routes from web frameworks
35
+
36
+ Usage: tl-routes [dir] [options]
37
+
38
+ Options:
39
+ --tree Show routes as tree structure
40
+ --with-components Show component/handler for each route
41
+ --framework <name> Force specific framework detection
42
+ ${COMMON_OPTIONS_HELP}
43
+
44
+ Examples:
45
+ tl-routes # Auto-detect framework
46
+ tl-routes src/ # Scan specific directory
47
+ tl-routes --tree # Tree view
48
+ tl-routes app/ --with-components
49
+
50
+ Detects:
51
+ - Next.js App Router (app/ directory structure)
52
+ - Next.js Pages Router (pages/ directory)
53
+ - React Router (createBrowserRouter, <Route>)
54
+ - Vue Router (createRouter, routes array)
55
+ - Express/Fastify routes
56
+ - SvelteKit (+page.svelte)
57
+ - Remix (routes/ directory)
58
+ `;
59
+
60
+ // ─────────────────────────────────────────────────────────────
61
+ // Framework Detection
62
+ // ─────────────────────────────────────────────────────────────
63
+
64
+ function detectFramework(projectRoot) {
65
+ // Check package.json for framework hints
66
+ const pkgPath = join(projectRoot, 'package.json');
67
+ if (existsSync(pkgPath)) {
68
+ try {
69
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
70
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
71
+
72
+ if (deps['next']) return 'nextjs';
73
+ if (deps['react-router'] || deps['react-router-dom']) return 'react-router';
74
+ if (deps['vue-router']) return 'vue-router';
75
+ if (deps['@sveltejs/kit']) return 'sveltekit';
76
+ if (deps['@remix-run/react']) return 'remix';
77
+ if (deps['express']) return 'express';
78
+ if (deps['fastify']) return 'fastify';
79
+ if (deps['hono']) return 'hono';
80
+ } catch {}
81
+ }
82
+
83
+ // Check for directory structures
84
+ if (existsSync(join(projectRoot, 'app')) && existsSync(join(projectRoot, 'app/page.tsx'))) {
85
+ return 'nextjs-app';
86
+ }
87
+ if (existsSync(join(projectRoot, 'src/app')) && existsSync(join(projectRoot, 'src/app/page.tsx'))) {
88
+ return 'nextjs-app';
89
+ }
90
+ if (existsSync(join(projectRoot, 'pages'))) {
91
+ return 'nextjs-pages';
92
+ }
93
+ if (existsSync(join(projectRoot, 'src/routes'))) {
94
+ return 'sveltekit';
95
+ }
96
+
97
+ return 'unknown';
98
+ }
99
+
100
+ // ─────────────────────────────────────────────────────────────
101
+ // Next.js App Router
102
+ // ─────────────────────────────────────────────────────────────
103
+
104
+ function extractNextAppRoutes(appDir, projectRoot) {
105
+ const routes = [];
106
+
107
+ function scanDir(dir, routePath = '') {
108
+ if (!existsSync(dir)) return;
109
+
110
+ const entries = readdirSync(dir, { withFileTypes: true });
111
+
112
+ for (const entry of entries) {
113
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
114
+
115
+ const fullPath = join(dir, entry.name);
116
+
117
+ if (entry.isDirectory()) {
118
+ let segment = entry.name;
119
+
120
+ // Handle special Next.js conventions
121
+ if (segment.startsWith('(') && segment.endsWith(')')) {
122
+ // Route group - doesn't affect URL
123
+ scanDir(fullPath, routePath);
124
+ } else if (segment.startsWith('[') && segment.endsWith(']')) {
125
+ // Dynamic segment
126
+ if (segment.startsWith('[...')) {
127
+ segment = '*'; // Catch-all
128
+ } else {
129
+ segment = `:${segment.slice(1, -1)}`;
130
+ }
131
+ scanDir(fullPath, `${routePath}/${segment}`);
132
+ } else if (segment.startsWith('@')) {
133
+ // Parallel route slot - skip
134
+ continue;
135
+ } else {
136
+ scanDir(fullPath, `${routePath}/${segment}`);
137
+ }
138
+ } else if (entry.isFile()) {
139
+ const name = entry.name.toLowerCase();
140
+
141
+ if (name === 'page.tsx' || name === 'page.jsx' || name === 'page.js' || name === 'page.ts') {
142
+ routes.push({
143
+ path: routePath || '/',
144
+ file: relative(projectRoot, fullPath),
145
+ type: 'page'
146
+ });
147
+ } else if (name === 'route.tsx' || name === 'route.ts' || name === 'route.js') {
148
+ routes.push({
149
+ path: routePath || '/',
150
+ file: relative(projectRoot, fullPath),
151
+ type: 'api'
152
+ });
153
+ } else if (name === 'layout.tsx' || name === 'layout.jsx' || name === 'layout.js') {
154
+ routes.push({
155
+ path: routePath || '/',
156
+ file: relative(projectRoot, fullPath),
157
+ type: 'layout'
158
+ });
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ scanDir(appDir);
165
+ return routes;
166
+ }
167
+
168
+ // ─────────────────────────────────────────────────────────────
169
+ // Next.js Pages Router
170
+ // ─────────────────────────────────────────────────────────────
171
+
172
+ function extractNextPagesRoutes(pagesDir, projectRoot) {
173
+ const routes = [];
174
+
175
+ function scanDir(dir, routePath = '') {
176
+ if (!existsSync(dir)) return;
177
+
178
+ const entries = readdirSync(dir, { withFileTypes: true });
179
+
180
+ for (const entry of entries) {
181
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
182
+
183
+ const fullPath = join(dir, entry.name);
184
+
185
+ if (entry.isDirectory()) {
186
+ let segment = entry.name;
187
+
188
+ if (segment.startsWith('[') && segment.endsWith(']')) {
189
+ if (segment.startsWith('[...')) {
190
+ segment = '*';
191
+ } else {
192
+ segment = `:${segment.slice(1, -1)}`;
193
+ }
194
+ }
195
+
196
+ scanDir(fullPath, `${routePath}/${segment}`);
197
+ } else if (entry.isFile()) {
198
+ const ext = extname(entry.name);
199
+ if (!['.tsx', '.jsx', '.js', '.ts'].includes(ext)) continue;
200
+
201
+ const name = basename(entry.name, ext);
202
+
203
+ if (name === 'index') {
204
+ routes.push({
205
+ path: routePath || '/',
206
+ file: relative(projectRoot, fullPath),
207
+ type: routePath.startsWith('/api') ? 'api' : 'page'
208
+ });
209
+ } else if (name.startsWith('[') && name.endsWith(']')) {
210
+ let segment = name;
211
+ if (segment.startsWith('[...')) {
212
+ segment = '*';
213
+ } else {
214
+ segment = `:${segment.slice(1, -1)}`;
215
+ }
216
+ routes.push({
217
+ path: `${routePath}/${segment}`,
218
+ file: relative(projectRoot, fullPath),
219
+ type: routePath.startsWith('/api') ? 'api' : 'page'
220
+ });
221
+ } else {
222
+ routes.push({
223
+ path: `${routePath}/${name}`,
224
+ file: relative(projectRoot, fullPath),
225
+ type: routePath.startsWith('/api') ? 'api' : 'page'
226
+ });
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ scanDir(pagesDir);
233
+ return routes;
234
+ }
235
+
236
+ // ─────────────────────────────────────────────────────────────
237
+ // React Router
238
+ // ─────────────────────────────────────────────────────────────
239
+
240
+ function extractReactRouterRoutes(content, filePath, projectRoot) {
241
+ const routes = [];
242
+ const relPath = relative(projectRoot, filePath);
243
+
244
+ // Match <Route path="..." element={...} /> or path: "..."
245
+ const routePatterns = [
246
+ /<Route\s+[^>]*path\s*=\s*["']([^"']+)["'][^>]*>/g,
247
+ /path\s*:\s*["']([^"']+)["']/g,
248
+ /createBrowserRouter\s*\(\s*\[[\s\S]*?path\s*:\s*["']([^"']+)["']/g
249
+ ];
250
+
251
+ for (const pattern of routePatterns) {
252
+ let match;
253
+ while ((match = pattern.exec(content)) !== null) {
254
+ const path = match[1];
255
+ if (!routes.some(r => r.path === path)) {
256
+ routes.push({
257
+ path,
258
+ file: relPath,
259
+ type: 'page'
260
+ });
261
+ }
262
+ }
263
+ }
264
+
265
+ return routes;
266
+ }
267
+
268
+ // ─────────────────────────────────────────────────────────────
269
+ // Vue Router
270
+ // ─────────────────────────────────────────────────────────────
271
+
272
+ function extractVueRouterRoutes(content, filePath, projectRoot) {
273
+ const routes = [];
274
+ const relPath = relative(projectRoot, filePath);
275
+
276
+ // Match path: '/...' in route configs
277
+ const pathPattern = /path\s*:\s*['"]([^'"]+)['"]/g;
278
+ let match;
279
+
280
+ while ((match = pathPattern.exec(content)) !== null) {
281
+ const path = match[1];
282
+ if (!routes.some(r => r.path === path)) {
283
+ routes.push({
284
+ path,
285
+ file: relPath,
286
+ type: 'page'
287
+ });
288
+ }
289
+ }
290
+
291
+ return routes;
292
+ }
293
+
294
+ // ─────────────────────────────────────────────────────────────
295
+ // SvelteKit
296
+ // ─────────────────────────────────────────────────────────────
297
+
298
+ function extractSvelteKitRoutes(routesDir, projectRoot) {
299
+ const routes = [];
300
+
301
+ function scanDir(dir, routePath = '') {
302
+ if (!existsSync(dir)) return;
303
+
304
+ const entries = readdirSync(dir, { withFileTypes: true });
305
+
306
+ for (const entry of entries) {
307
+ if (entry.name.startsWith('.')) continue;
308
+
309
+ const fullPath = join(dir, entry.name);
310
+
311
+ if (entry.isDirectory()) {
312
+ let segment = entry.name;
313
+
314
+ if (segment.startsWith('(') && segment.endsWith(')')) {
315
+ scanDir(fullPath, routePath);
316
+ } else if (segment.startsWith('[') && segment.endsWith(']')) {
317
+ segment = `:${segment.slice(1, -1)}`;
318
+ scanDir(fullPath, `${routePath}/${segment}`);
319
+ } else {
320
+ scanDir(fullPath, `${routePath}/${segment}`);
321
+ }
322
+ } else if (entry.isFile()) {
323
+ if (entry.name === '+page.svelte') {
324
+ routes.push({
325
+ path: routePath || '/',
326
+ file: relative(projectRoot, fullPath),
327
+ type: 'page'
328
+ });
329
+ } else if (entry.name === '+server.js' || entry.name === '+server.ts') {
330
+ routes.push({
331
+ path: routePath || '/',
332
+ file: relative(projectRoot, fullPath),
333
+ type: 'api'
334
+ });
335
+ } else if (entry.name === '+layout.svelte') {
336
+ routes.push({
337
+ path: routePath || '/',
338
+ file: relative(projectRoot, fullPath),
339
+ type: 'layout'
340
+ });
341
+ }
342
+ }
343
+ }
344
+ }
345
+
346
+ scanDir(routesDir);
347
+ return routes;
348
+ }
349
+
350
+ // ─────────────────────────────────────────────────────────────
351
+ // File Discovery
352
+ // ─────────────────────────────────────────────────────────────
353
+
354
+ const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.jsx', '.ts', '.tsx']);
355
+
356
+ function findCodeFiles(dir, files = []) {
357
+ if (!existsSync(dir)) return files;
358
+
359
+ const entries = readdirSync(dir, { withFileTypes: true });
360
+
361
+ for (const entry of entries) {
362
+ const fullPath = join(dir, entry.name);
363
+
364
+ if (entry.isDirectory()) {
365
+ if (!shouldSkip(entry.name, true)) {
366
+ findCodeFiles(fullPath, files);
367
+ }
368
+ } else if (entry.isFile()) {
369
+ const ext = extname(entry.name).toLowerCase();
370
+ if (CODE_EXTENSIONS.has(ext) && !shouldSkip(entry.name, false)) {
371
+ files.push(fullPath);
372
+ }
373
+ }
374
+ }
375
+
376
+ return files;
377
+ }
378
+
379
+ // ─────────────────────────────────────────────────────────────
380
+ // Output Formatting
381
+ // ─────────────────────────────────────────────────────────────
382
+
383
+ function buildRouteTree(routes) {
384
+ const tree = { children: {}, routes: [] };
385
+
386
+ for (const route of routes) {
387
+ const parts = route.path.split('/').filter(Boolean);
388
+ let node = tree;
389
+
390
+ for (const part of parts) {
391
+ if (!node.children[part]) {
392
+ node.children[part] = { children: {}, routes: [] };
393
+ }
394
+ node = node.children[part];
395
+ }
396
+
397
+ node.routes.push(route);
398
+ }
399
+
400
+ return tree;
401
+ }
402
+
403
+ function printTree(node, out, prefix = '', isLast = true, path = '') {
404
+ const childKeys = Object.keys(node.children);
405
+
406
+ // Print routes at this level
407
+ for (const route of node.routes) {
408
+ const typeIcon = route.type === 'api' ? '⚡' : route.type === 'layout' ? '📐' : '📄';
409
+ out.add(`${prefix}${typeIcon} ${route.path || '/'}`);
410
+ }
411
+
412
+ // Print children
413
+ childKeys.forEach((key, index) => {
414
+ const child = node.children[key];
415
+ const isLastChild = index === childKeys.length - 1;
416
+ const newPrefix = prefix + (isLast ? ' ' : '│ ');
417
+ const branch = isLastChild ? '└─' : '├─';
418
+
419
+ out.add(`${prefix}${branch} /${key}`);
420
+ printTree(child, out, newPrefix, isLastChild, `${path}/${key}`);
421
+ });
422
+ }
423
+
424
+ // ─────────────────────────────────────────────────────────────
425
+ // Main
426
+ // ─────────────────────────────────────────────────────────────
427
+
428
+ const args = process.argv.slice(2);
429
+ const options = parseCommonArgs(args);
430
+
431
+ // Parse custom options
432
+ let treeView = false;
433
+ let withComponents = false;
434
+ let forcedFramework = null;
435
+
436
+ const remaining = [];
437
+ for (let i = 0; i < options.remaining.length; i++) {
438
+ const arg = options.remaining[i];
439
+
440
+ if (arg === '--tree') {
441
+ treeView = true;
442
+ } else if (arg === '--with-components') {
443
+ withComponents = true;
444
+ } else if (arg === '--framework') {
445
+ forcedFramework = options.remaining[++i];
446
+ } else if (!arg.startsWith('-')) {
447
+ remaining.push(arg);
448
+ }
449
+ }
450
+
451
+ const targetDir = remaining[0] || '.';
452
+
453
+ if (options.help) {
454
+ console.log(HELP);
455
+ process.exit(0);
456
+ }
457
+
458
+ if (!existsSync(targetDir)) {
459
+ console.error(`Directory not found: ${targetDir}`);
460
+ process.exit(1);
461
+ }
462
+
463
+ // Resolve target directory to absolute path
464
+ const targetAbsolute = targetDir.startsWith('/') ? targetDir : join(process.cwd(), targetDir);
465
+
466
+ // Use target directory as project root if it has package.json, otherwise find from cwd
467
+ const projectRoot = existsSync(join(targetAbsolute, 'package.json'))
468
+ ? targetAbsolute
469
+ : findProjectRoot(targetAbsolute);
470
+ const out = createOutput(options);
471
+
472
+ // Detect framework from target directory
473
+ const framework = forcedFramework || detectFramework(targetAbsolute);
474
+
475
+ let routes = [];
476
+
477
+ // Extract routes based on framework
478
+ const searchRoot = targetAbsolute;
479
+
480
+ if (framework === 'nextjs' || framework === 'nextjs-app') {
481
+ // Try app directory first
482
+ const appDirs = [
483
+ join(searchRoot, 'app'),
484
+ join(searchRoot, 'src/app')
485
+ ];
486
+
487
+ for (const appDir of appDirs) {
488
+ if (existsSync(appDir)) {
489
+ routes = extractNextAppRoutes(appDir, searchRoot);
490
+ break;
491
+ }
492
+ }
493
+
494
+ // Also check pages directory
495
+ const pagesDirs = [
496
+ join(searchRoot, 'pages'),
497
+ join(searchRoot, 'src/pages')
498
+ ];
499
+
500
+ for (const pagesDir of pagesDirs) {
501
+ if (existsSync(pagesDir)) {
502
+ routes.push(...extractNextPagesRoutes(pagesDir, searchRoot));
503
+ }
504
+ }
505
+ } else if (framework === 'nextjs-pages') {
506
+ const pagesDirs = [
507
+ join(searchRoot, 'pages'),
508
+ join(searchRoot, 'src/pages')
509
+ ];
510
+
511
+ for (const pagesDir of pagesDirs) {
512
+ if (existsSync(pagesDir)) {
513
+ routes = extractNextPagesRoutes(pagesDir, searchRoot);
514
+ break;
515
+ }
516
+ }
517
+ } else if (framework === 'sveltekit') {
518
+ const routesDirs = [
519
+ join(searchRoot, 'src/routes'),
520
+ join(searchRoot, 'routes')
521
+ ];
522
+
523
+ for (const routesDir of routesDirs) {
524
+ if (existsSync(routesDir)) {
525
+ routes = extractSvelteKitRoutes(routesDir, searchRoot);
526
+ break;
527
+ }
528
+ }
529
+ } else if (framework === 'react-router' || framework === 'vue-router') {
530
+ // Scan code files for route definitions
531
+ const files = findCodeFiles(searchRoot);
532
+
533
+ for (const file of files) {
534
+ const content = readFileSync(file, 'utf-8');
535
+
536
+ if (framework === 'react-router') {
537
+ routes.push(...extractReactRouterRoutes(content, file, searchRoot));
538
+ } else {
539
+ routes.push(...extractVueRouterRoutes(content, file, searchRoot));
540
+ }
541
+ }
542
+ } else {
543
+ // Generic scan - look for common route patterns
544
+ const files = findCodeFiles(searchRoot);
545
+
546
+ for (const file of files) {
547
+ const content = readFileSync(file, 'utf-8');
548
+ routes.push(...extractReactRouterRoutes(content, file, searchRoot));
549
+ routes.push(...extractVueRouterRoutes(content, file, searchRoot));
550
+ }
551
+ }
552
+
553
+ // Deduplicate routes
554
+ const seen = new Set();
555
+ routes = routes.filter(r => {
556
+ const key = `${r.path}:${r.type}`;
557
+ if (seen.has(key)) return false;
558
+ seen.add(key);
559
+ return true;
560
+ });
561
+
562
+ // Sort routes
563
+ routes.sort((a, b) => a.path.localeCompare(b.path));
564
+
565
+ // Set JSON data
566
+ out.setData('framework', framework);
567
+ out.setData('routes', routes);
568
+ out.setData('totalRoutes', routes.length);
569
+
570
+ // Output
571
+ out.header(`🛤️ Routes (${framework})`);
572
+ out.blank();
573
+
574
+ if (routes.length === 0) {
575
+ out.add('No routes found');
576
+ } else if (treeView) {
577
+ const tree = buildRouteTree(routes);
578
+ printTree(tree, out);
579
+ } else {
580
+ // Group by type
581
+ const pages = routes.filter(r => r.type === 'page');
582
+ const apis = routes.filter(r => r.type === 'api');
583
+ const layouts = routes.filter(r => r.type === 'layout');
584
+
585
+ if (pages.length > 0) {
586
+ out.add('Pages:');
587
+ for (const route of pages) {
588
+ const component = withComponents ? ` → ${route.file}` : '';
589
+ out.add(` ${route.path}${component}`);
590
+ }
591
+ out.blank();
592
+ }
593
+
594
+ if (apis.length > 0) {
595
+ out.add('API Routes:');
596
+ for (const route of apis) {
597
+ const component = withComponents ? ` → ${route.file}` : '';
598
+ out.add(` ${route.path}${component}`);
599
+ }
600
+ out.blank();
601
+ }
602
+
603
+ if (layouts.length > 0) {
604
+ out.add('Layouts:');
605
+ for (const route of layouts) {
606
+ const component = withComponents ? ` → ${route.file}` : '';
607
+ out.add(` ${route.path}${component}`);
608
+ }
609
+ out.blank();
610
+ }
611
+ }
612
+
613
+ // Summary
614
+ if (!options.quiet && routes.length > 0) {
615
+ const pages = routes.filter(r => r.type === 'page').length;
616
+ const apis = routes.filter(r => r.type === 'api').length;
617
+ const layouts = routes.filter(r => r.type === 'layout').length;
618
+
619
+ const parts = [];
620
+ if (pages > 0) parts.push(`${pages} pages`);
621
+ if (apis > 0) parts.push(`${apis} API`);
622
+ if (layouts > 0) parts.push(`${layouts} layouts`);
623
+
624
+ out.add(`Total: ${parts.join(', ')}`);
625
+ }
626
+
627
+ out.print();