Skip to content

Commit 6c6c7d5

Browse files
committed
refactor: simplify src dirs
1 parent 2741ecb commit 6c6c7d5

File tree

6 files changed

+268
-270
lines changed

6 files changed

+268
-270
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
- **Monorepo**: pnpm workspace with packages in `packages/` directory
1616
- **Key packages**: route-collector
1717
- **Package exports**: All `exports` in `package.json` have a dedicated file in `src` that defines the public API by
18-
re-exporting from within `src/lib`
18+
re-exporting from `src` or from files within `src/lib` (for more granular exports).
1919
- **Philosophy**: Web standards-first, runtime-agnostic (Node.js, Bun, Deno, Cloudflare Workers). Use Web Streams API,
2020
Uint8Array, Web Crypto API, Blob/File instead of Node.js APIs
2121
- **Tests run from source** (no build required), using Node.js test runner

packages/route-collector/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
}
5252
},
5353
"scripts": {
54-
"build": "tsgo -p tsconfig.build.json",
54+
"build": "rm -rf dist && tsgo -p tsconfig.build.json",
5555
"clean": "git clean -fdX",
5656
"prepublishOnly": "pnpm run build",
5757
"test": "node --disable-warning=ExperimentalWarning --test './src/**/*.test.ts'",

packages/route-collector/src/fs.ts

Lines changed: 248 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,248 @@
1-
export { FileSystemRouter, type RouteDefinition, type FileSystemRouterOptions, collectRoutes } from './lib/fs.ts'
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
4+
export type RouteDefinition =
5+
| {
6+
route: string
7+
filename: string
8+
layout: string | null
9+
isLayout?: never
10+
siblings?: never
11+
}
12+
| {
13+
filename: string
14+
isLayout: true
15+
siblings: RouteDefinition[]
16+
}
17+
18+
export type FileSystemRouterOptions = {
19+
projectRoot?: string
20+
routesDir?: string
21+
fileExtensions?: string[]
22+
ignoredPaths?: string[]
23+
ignoredPathPrefix?: string
24+
mdxRendererFile?: string
25+
}
26+
27+
export class FileSystemRouter {
28+
#fileExts: string[]
29+
#fileExtsMatcher: string
30+
#projectRoot: string
31+
#ignoredPaths: string[]
32+
#ignoredPathPrefix: string
33+
#mdxRendererFile: string | undefined
34+
35+
constructor(options: FileSystemRouterOptions) {
36+
this.#fileExts = (options.fileExtensions ?? ['.tsx', '.ts']).map((ext) => ext.toLowerCase())
37+
this.#fileExtsMatcher = `(${this.#fileExts.map((ext) => ext.replace('.', '')).join('|')})`
38+
this.#projectRoot = options.projectRoot ?? process.cwd()
39+
this.#ignoredPaths = options.ignoredPaths ?? []
40+
this.#ignoredPathPrefix = options.ignoredPathPrefix ?? '_'
41+
this.#mdxRendererFile = options.mdxRendererFile
42+
}
43+
44+
#isSupportedFile(filename: string): boolean {
45+
return this.#fileExts.some((ext) => filename.endsWith(ext))
46+
}
47+
48+
#isIndexFile(filename: string): boolean {
49+
return filename.match(new RegExp(`^(index|page)\.${this.#fileExtsMatcher}$`)) !== null
50+
}
51+
52+
#isLayoutFile(filename: string): boolean {
53+
return (
54+
filename.match(new RegExp(`^layout\.${this.#fileExtsMatcher}$`)) !== null ||
55+
filename.match(new RegExp(`\/layout\.${this.#fileExtsMatcher}$`)) !== null
56+
)
57+
}
58+
59+
#isDtsFile(filename: string): boolean {
60+
// matches .d.ts, .d.json.ts, etc.
61+
return filename.endsWith('.d.ts') || filename.match(/\.d\.([a-z0-9]+).ts$/) !== null
62+
}
63+
64+
#isIgnoredPath(filename: string): boolean {
65+
return filename.startsWith(this.#ignoredPathPrefix) || this.#ignoredPaths.includes(filename)
66+
}
67+
68+
#isGrouping(filename: string): boolean {
69+
return /^\(.*\)$/.test(filename)
70+
}
71+
72+
#isSplatRoute(filename: string): boolean {
73+
return filename.startsWith('[...') && filename.endsWith(']')
74+
}
75+
76+
#isDynamicRoute(filename: string): boolean {
77+
return filename.startsWith('[') && filename.endsWith(']')
78+
}
79+
80+
#transformSegment(routeName: string, basePath: string): string {
81+
let routePath = ''
82+
if (routeName === 'index' || routeName === 'page') {
83+
routePath = basePath
84+
} else if (this.#isSplatRoute(routeName)) {
85+
// Catch-all or Splat route, @see https://reactrouter.com/start/framework/routing#splats
86+
// const param = routeName.slice(4, -1)
87+
routePath = path.join(basePath, '*')
88+
} else if (this.#isDynamicRoute(routeName)) {
89+
// Dynamic segment route
90+
let param = routeName.slice(1, -1)
91+
if (param.startsWith('[') && routeName.endsWith(']')) {
92+
// Optional segment route
93+
param = param.slice(1, -1) + '?'
94+
}
95+
routePath = path.join(basePath, `:${param}`)
96+
} else {
97+
routePath = path.join(basePath, routeName)
98+
}
99+
100+
// Clean up the route path
101+
routePath = routePath.replace(/\\/g, '/') // Convert Windows paths
102+
return routePath
103+
}
104+
105+
#isMdxFile(filename: string): boolean {
106+
return filename.toLowerCase().endsWith('.mdx') || filename.toLowerCase().endsWith('.md')
107+
}
108+
109+
#safeLayoutFile(layoutFile: string): string {
110+
if (this.#isMdxFile(layoutFile)) {
111+
throw new Error(
112+
`Markdown Layout files are not supported: ${layoutFile}.` +
113+
'If your intention was to create a route with a layout/ path segment, ' +
114+
`use ./layout/index.mdx instead.`,
115+
)
116+
}
117+
return layoutFile
118+
}
119+
120+
collectRoutes(
121+
dir: string = 'app/routes',
122+
basePath: string = '',
123+
parentLayoutFile: string | null = null,
124+
): RouteDefinition[] {
125+
let routesDir = path.join(this.#projectRoot, dir)
126+
if (!fs.existsSync(routesDir)) {
127+
throw new Error(`[app-router-fs] Routes directory not found: ${routesDir}`)
128+
}
129+
130+
let entries = fs.readdirSync(routesDir, { withFileTypes: true })
131+
let routes: RouteDefinition[] = []
132+
let layoutFile: string | null = parentLayoutFile
133+
134+
// First pass: find layout file if it exists
135+
for (let entry of entries) {
136+
if (!entry.isDirectory() && this.#isLayoutFile(entry.name)) {
137+
layoutFile = path.join(dir, entry.name).replace(/\\/g, '/')
138+
break
139+
}
140+
}
141+
142+
// Sort entries to ensure index files come first
143+
entries.sort((a, b) => {
144+
if (this.#isIndexFile(a.name)) return -1
145+
if (this.#isIndexFile(b.name)) return 1
146+
return a.name.localeCompare(b.name)
147+
})
148+
149+
// Second pass: process all routes
150+
let processedRoutes: RouteDefinition[] = []
151+
for (let entry of entries) {
152+
// Skip files/folders starting with underscore and layout files (handled separately)
153+
if (this.#isIgnoredPath(entry.name) || this.#isLayoutFile(entry.name)) {
154+
continue
155+
}
156+
157+
let routeName = entry.name.replace(new RegExp(`\\.${this.#fileExtsMatcher}$`), '') // Remove extensions
158+
159+
if (entry.isDirectory()) {
160+
// Skip if directory is a special route directory (starts with underscore)
161+
if (routeName.startsWith('_')) {
162+
continue
163+
}
164+
165+
// Handle parentheses folders - they don't count as segments
166+
let newBasePath = this.#isGrouping(routeName)
167+
? basePath
168+
: path.join(basePath, this.#transformSegment(routeName, ''))
169+
let childRoutes = this.collectRoutes(path.join(dir, entry.name), newBasePath, layoutFile)
170+
processedRoutes.push(...childRoutes)
171+
} else {
172+
// Skip non-route files
173+
if (
174+
this.#isDtsFile(entry.name) || // Skip d.ts files
175+
!this.#isSupportedFile(entry.name) || // Skip if not matching any supported extension
176+
this.#isIgnoredPath(entry.name) || // Skip ignored files and dirs with leading underscore
177+
this.#isLayoutFile(entry.name) // Skip layout files
178+
) {
179+
continue
180+
}
181+
182+
let routePath = this.#transformSegment(routeName, basePath)
183+
let routeFilePath = path.join(dir, entry.name)
184+
185+
// Clean up the route path
186+
routePath = routePath.replace(/\\/g, '/') // Convert Windows paths
187+
if (routePath.startsWith('/')) {
188+
routePath = routePath.slice(1)
189+
}
190+
191+
processedRoutes.push({
192+
route: routePath,
193+
layout: layoutFile ? this.#safeLayoutFile(layoutFile) : null,
194+
filename:
195+
this.#isMdxFile(routeFilePath) && this.#mdxRendererFile !== undefined
196+
? this.#mdxRendererFile
197+
: routeFilePath,
198+
})
199+
}
200+
}
201+
202+
// If we found a layout file, create a layout route with all siblings
203+
if (layoutFile && layoutFile !== parentLayoutFile) {
204+
routes.push({
205+
filename: this.#safeLayoutFile(layoutFile),
206+
isLayout: true,
207+
siblings: processedRoutes,
208+
})
209+
} else {
210+
routes.push(...processedRoutes)
211+
}
212+
213+
return routes
214+
}
215+
216+
#getRoutesAsTable(routes: RouteDefinition[]): { route: string; filename: string; layout: string }[] {
217+
return routes.flatMap((r) => {
218+
if (r.isLayout) {
219+
return this.#getRoutesAsTable(r.siblings)
220+
}
221+
return [{ route: '/' + r.route, filename: r.filename, layout: r.layout ?? 'root' }]
222+
})
223+
}
224+
225+
debug(routes: RouteDefinition[]) {
226+
console.log(JSON.stringify(routes, null, 2))
227+
let tableData = this.#getRoutesAsTable(routes)
228+
console.log('\n🚀 Route List:')
229+
console.table(tableData)
230+
console.log('Supported page extensions:', this.#fileExts.join(', '))
231+
}
232+
}
233+
234+
export function collectRoutes(options?: FileSystemRouterOptions): RouteDefinition[] {
235+
let _options = Object.assign(
236+
{
237+
routesDir: 'app/routes',
238+
fileExtensions: ['.tsx', '.ts'],
239+
projectRoot: process.cwd(),
240+
ignoredPaths: [],
241+
ignoredPathPrefix: '_',
242+
mdxRendererFile: undefined,
243+
} satisfies FileSystemRouterOptions,
244+
options,
245+
)
246+
247+
return new FileSystemRouter(_options).collectRoutes(_options.routesDir)
248+
}

0 commit comments

Comments
 (0)