|
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