1import type { DynamicConvention, RouteNode } from './Route'; 2import EXPO_ROUTER_IMPORT_MODE from './import-mode'; 3import { 4 getNameFromFilePath, 5 matchDeepDynamicRouteName, 6 matchDynamicName, 7 matchGroupName, 8 removeSupportedExtensions, 9 stripGroupSegmentsFromPath, 10 stripInvisibleSegmentsFromPath, 11} from './matchers'; 12import type { RequireContext } from './types'; 13 14export type FileNode = Pick<RouteNode, 'contextKey' | 'loadRoute'> & { 15 /** Like `(tab)/index` */ 16 normalizedName: string; 17}; 18 19type TreeNode = { 20 name: string; 21 children: TreeNode[]; 22 parents: string[]; 23 /** null when there is no file in a folder. */ 24 node: FileNode | null; 25}; 26 27type Options = { 28 ignore?: RegExp[]; 29 preserveApiRoutes?: boolean; 30 ignoreRequireErrors?: boolean; 31}; 32 33/** Convert a flat map of file nodes into a nested tree of files. */ 34export function getRecursiveTree(files: FileNode[]): TreeNode { 35 const tree = { 36 name: '', 37 children: [], 38 parents: [], 39 node: null, 40 }; 41 42 for (const file of files) { 43 // ['(tab)', 'settings', '[...another]'] 44 const parts = file.normalizedName.split('/'); 45 let currentNode: TreeNode = tree; 46 for (let i = 0; i < parts.length; i++) { 47 const part = parts[i]; 48 49 if (i === parts.length - 1 && part === '_layout') { 50 if (currentNode.node) { 51 const overwritten = currentNode.node.contextKey; 52 throw new Error( 53 `Higher priority Layout Route "${file.contextKey}" overriding redundant Layout Route "${overwritten}". Remove the Layout Route "${overwritten}" to fix this.` 54 ); 55 } 56 continue; 57 } 58 59 const existing = currentNode.children.find((item) => item.name === part); 60 if (existing) { 61 currentNode = existing; 62 } else { 63 const newNode: TreeNode = { 64 name: part, 65 children: [], 66 parents: [...currentNode.parents, currentNode.name], 67 node: null, 68 }; 69 currentNode.children.push(newNode); 70 currentNode = newNode; 71 } 72 } 73 currentNode.node = file; 74 } 75 76 if (process.env.NODE_ENV !== 'production') { 77 assertDeprecatedFormat(tree); 78 } 79 80 return tree; 81} 82 83function assertDeprecatedFormat(tree: TreeNode) { 84 for (const child of tree.children) { 85 if (child.node && child.children.length && !child.node.normalizedName.endsWith('_layout')) { 86 const ext = child.node.contextKey.split('.').pop(); 87 throw new Error( 88 `Using deprecated Layout Route format: Move \`./app/${child.node.normalizedName}.${ext}\` to \`./app/${child.node.normalizedName}/_layout.${ext}\`` 89 ); 90 } 91 assertDeprecatedFormat(child); 92 } 93} 94 95function getTreeNodesAsRouteNodes(nodes: TreeNode[]): RouteNode[] { 96 return nodes 97 .map((node) => treeNodeToRouteNode(node)) 98 .flat() 99 .filter(Boolean) as RouteNode[]; 100} 101 102export function generateDynamicFromSegment(name: string): DynamicConvention | null { 103 const deepDynamicName = matchDeepDynamicRouteName(name); 104 const dynamicName = deepDynamicName ?? matchDynamicName(name); 105 106 return dynamicName ? { name: dynamicName, deep: !!deepDynamicName } : null; 107} 108 109export function generateDynamic(name: string): RouteNode['dynamic'] { 110 const description = name 111 .split('/') 112 .map((segment) => generateDynamicFromSegment(segment)) 113 .filter(Boolean) as DynamicConvention[]; 114 return description.length === 0 ? null : description; 115} 116 117function collapseRouteSegments(route: string) { 118 return stripGroupSegmentsFromPath(route.replace(/\/index$/, '')); 119} 120 121/** 122 * Given a route node and a name representing the group name, 123 * find the nearest child matching the name. 124 * 125 * Doesn't support slashes in the name. 126 * Routes like `explore/(something)/index` will be matched against `explore`. 127 * 128 */ 129function getDefaultInitialRoute(node: RouteNode, name: string) { 130 return node.children.find((node) => collapseRouteSegments(node.route) === name); 131} 132 133function applyDefaultInitialRouteName(node: RouteNode): RouteNode { 134 const groupName = matchGroupName(node.route); 135 if (!node.children?.length) { 136 return node; 137 } 138 139 // Guess at the initial route based on the group name. 140 // TODO(EvanBacon): Perhaps we should attempt to warn when the group doesn't match any child routes. 141 let initialRouteName = groupName ? getDefaultInitialRoute(node, groupName)?.route : undefined; 142 const loaded = node.loadRoute(); 143 144 if (loaded?.unstable_settings) { 145 // Allow unstable_settings={ initialRouteName: '...' } to override the default initial route name. 146 initialRouteName = loaded.unstable_settings.initialRouteName ?? initialRouteName; 147 148 if (groupName) { 149 // Allow unstable_settings={ 'custom': { initialRouteName: '...' } } to override the less specific initial route name. 150 const groupSpecificInitialRouteName = loaded.unstable_settings?.[groupName]?.initialRouteName; 151 152 initialRouteName = groupSpecificInitialRouteName ?? initialRouteName; 153 } 154 } 155 156 return { 157 ...node, 158 initialRouteName, 159 }; 160} 161 162function folderNodeToRouteNode({ name, children }: TreeNode): RouteNode[] | null { 163 // Empty folder, skip it. 164 if (!children.length) { 165 return null; 166 } 167 168 // When there's a directory, but no layout route file (with valid export), the child routes won't be grouped. 169 // This pushes all children into the nearest layout route. 170 return getTreeNodesAsRouteNodes( 171 children.map((child) => { 172 return { 173 ...child, 174 name: [name, child.name].filter(Boolean).join('/'), 175 }; 176 }) 177 ); 178} 179 180function fileNodeToRouteNode(tree: TreeNode): RouteNode[] | null { 181 const { name, node, children } = tree; 182 183 if (!node) throw new Error('node must be defined'); 184 185 const dynamic = generateDynamic(name); 186 187 const clones = extrapolateGroupRoutes(name, node.contextKey); 188 clones.delete(name); 189 190 const output = { 191 loadRoute: node.loadRoute, 192 route: name, 193 contextKey: node.contextKey, 194 children: getTreeNodesAsRouteNodes(children), 195 dynamic, 196 }; 197 198 if (clones.size) { 199 return [...clones].map((clone) => 200 applyDefaultInitialRouteName({ 201 ...output, 202 contextKey: node.contextKey.replace(output.route, clone), 203 route: clone, 204 }) 205 ); 206 } 207 208 return [ 209 applyDefaultInitialRouteName({ 210 loadRoute: node.loadRoute, 211 route: name, 212 contextKey: node.contextKey, 213 children: getTreeNodesAsRouteNodes(children), 214 dynamic, 215 }), 216 ]; 217} 218 219function extrapolateGroupRoutes( 220 route: string, 221 contextKey: string, 222 routes: Set<string> = new Set() 223): Set<string> { 224 const match = matchGroupName(route); 225 226 if (!match) { 227 routes.add(route); 228 return routes; 229 } 230 231 const groups = match?.split(','); 232 const groupsSet = new Set(groups); 233 234 if (groupsSet.size !== groups.length) { 235 throw new Error( 236 `Array syntax cannot contain duplicate group name "${groups}" in "${contextKey}".` 237 ); 238 } 239 240 if (groups.length === 1) { 241 routes.add(route); 242 return routes; 243 } 244 245 for (const group of groups) { 246 extrapolateGroupRoutes(route.replace(match, group.trim()), contextKey, routes); 247 } 248 249 return routes; 250} 251 252function treeNodeToRouteNode(tree: TreeNode): RouteNode[] | null { 253 if (tree.node) { 254 return fileNodeToRouteNode(tree); 255 } 256 257 return folderNodeToRouteNode(tree); 258} 259 260function contextModuleToFileNodes( 261 contextModule: RequireContext, 262 options: Options = {}, 263 files: string[] = contextModule.keys() 264): FileNode[] { 265 const nodes = files.map((key) => { 266 // In development, check if the file exports a default component 267 // this helps keep things snappy when creating files. In production we load all screens lazily. 268 try { 269 if (process.env.NODE_ENV === 'development') { 270 // If the user has set the `EXPO_ROUTER_IMPORT_MODE` to `sync` then we should 271 // filter the missing routes. 272 if (EXPO_ROUTER_IMPORT_MODE === 'sync') { 273 const isApi = key.match(/\+api\.[jt]sx?$/); 274 if (!isApi && !contextModule(key)?.default) { 275 return null; 276 } 277 } 278 } 279 const node: FileNode = { 280 loadRoute() { 281 if (options.ignoreRequireErrors) { 282 try { 283 return contextModule(key); 284 } catch { 285 return {}; 286 } 287 } else { 288 return contextModule(key); 289 } 290 }, 291 normalizedName: getNameFromFilePath(key), 292 contextKey: key, 293 }; 294 295 return node; 296 } catch (error) { 297 // Probably this won't stop metro from freaking out but it's worth a try. 298 console.warn('Error loading route "' + key + '"', error); 299 } 300 return null; 301 }); 302 303 return nodes.filter(Boolean) as FileNode[]; 304} 305 306function hasCustomRootLayoutNode(routes: RouteNode[]) { 307 if (routes.length !== 1) { 308 return false; 309 } 310 // This could either be the root _layout or an app with a single file. 311 const route = routes[0]; 312 313 if (route.route === '' && route.contextKey.match(/^\.\/_layout\.([jt]sx?)$/)) { 314 return true; 315 } 316 return false; 317} 318 319function treeNodesToRootRoute(treeNode: TreeNode): RouteNode | null { 320 const routes = treeNodeToRouteNode(treeNode); 321 return withOptionalRootLayout(routes); 322} 323 324function processKeys(files: string[], options: Options): string[] { 325 const { ignore } = options; 326 327 return files.filter((file) => { 328 return !ignore?.some((pattern) => pattern.test(file)); 329 }); 330} 331 332/** 333 * Asserts if the require.context has files that share the same name but have different extensions. Exposed for testing. 334 * @private 335 */ 336export function assertDuplicateRoutes(filenames: string[]) { 337 if (process.env.NODE_ENV === 'production') { 338 return; 339 } 340 341 const duplicates = filenames 342 .map((filename) => removeSupportedExtensions(filename)) 343 .reduce( 344 (acc, filename) => { 345 acc[filename] = acc[filename] ? acc[filename] + 1 : 1; 346 return acc; 347 }, 348 {} as Record<string, number> 349 ); 350 351 Object.entries(duplicates).forEach(([filename, count]) => { 352 if (count > 1) { 353 throw new Error(`Multiple files match the route name "${filename}".`); 354 } 355 }); 356} 357 358/** Given a Metro context module, return an array of nested routes. */ 359export function getRoutes(contextModule: RequireContext, options?: Options): RouteNode | null { 360 const route = getExactRoutes(contextModule, options); 361 362 // If there is no route, return an empty route. 363 if (!route) { 364 return null; 365 } 366 367 appendSitemapRoute(route); 368 369 // Auto add not found route if it doesn't exist 370 appendUnmatchedRoute(route); 371 372 return route; 373} 374 375export async function getRoutesAsync( 376 contextModule: RequireContext, 377 options?: Options 378): Promise<RouteNode | null> { 379 const route = await getExactRoutesAsync(contextModule, options); 380 if (!route) { 381 return null; 382 } 383 384 appendSitemapRoute(route); 385 386 // Auto add not found route if it doesn't exist 387 appendUnmatchedRoute(route); 388 389 return route; 390} 391 392function getIgnoreList(options?: Options) { 393 const ignore: RegExp[] = [/^\.\/\+html\.[tj]sx?$/, ...(options?.ignore ?? [])]; 394 if (options?.preserveApiRoutes !== true) { 395 ignore.push(/\+api\.[tj]sx?$/); 396 } 397 return ignore; 398} 399 400/** Get routes without unmatched or sitemap. */ 401export function getExactRoutes(contextModule: RequireContext, options?: Options): RouteNode | null { 402 const treeNodes = contextModuleToTree(contextModule, options); 403 const route = treeNodesToRootRoute(treeNodes); 404 return route || null; 405} 406 407function contextModuleToTree(contextModule: RequireContext, options?: Options) { 408 const allowed = processKeys(contextModule.keys(), { 409 ...options, 410 ignore: getIgnoreList(options), 411 }); 412 assertDuplicateRoutes(allowed); 413 const files = contextModuleToFileNodes(contextModule, options, allowed); 414 return getRecursiveTree(files); 415} 416 417export async function getExactRoutesAsync( 418 contextModule: RequireContext, 419 options?: Options 420): Promise<RouteNode | null> { 421 const treeNodes = contextModuleToTree(contextModule, options); 422 const route = treeNodesToRootRoute(treeNodes); 423 return route || null; 424} 425 426function appendSitemapRoute(routes: RouteNode) { 427 if ( 428 !routes.children.length || 429 // Allow overriding the sitemap route 430 routes.children.some((route) => route.route === '_sitemap') 431 ) { 432 return routes; 433 } 434 routes.children.push({ 435 loadRoute() { 436 const { Sitemap, getNavOptions } = require('./views/Sitemap'); 437 return { default: Sitemap, getNavOptions }; 438 }, 439 route: '_sitemap', 440 contextKey: './_sitemap.tsx', 441 generated: true, 442 internal: true, 443 dynamic: null, 444 children: [], 445 }); 446 return routes; 447} 448 449function appendUnmatchedRoute(routes: RouteNode) { 450 // Auto add not found route if it doesn't exist 451 const userDefinedDynamicRoute = getUserDefinedDeepDynamicRoute(routes); 452 if (!userDefinedDynamicRoute) { 453 routes.children.push({ 454 loadRoute() { 455 return { default: require('./views/Unmatched').Unmatched }; 456 }, 457 route: '[...404]', 458 contextKey: './[...404].tsx', 459 dynamic: [{ name: '404', deep: true }], 460 children: [], 461 generated: true, 462 internal: true, 463 }); 464 } 465 return routes; 466} 467 468/** 469 * Exposed for testing. 470 * @returns a top-level deep dynamic route if it exists, otherwise null. 471 */ 472export function getUserDefinedDeepDynamicRoute(routes: RouteNode): RouteNode | null { 473 // Auto add not found route if it doesn't exist 474 for (const route of routes.children ?? []) { 475 if (route.generated) continue; 476 const opaqueRoute = stripInvisibleSegmentsFromPath(route.route); 477 const isDeepDynamic = matchDeepDynamicRouteName(opaqueRoute); 478 if (isDeepDynamic) { 479 return route; 480 } 481 // Recurse through group routes 482 if (matchGroupName(route.route)) { 483 const child = getUserDefinedDeepDynamicRoute(route); 484 if (child) { 485 return child; 486 } 487 } 488 } 489 return null; 490} 491 492function withOptionalRootLayout(routes: RouteNode[] | null): RouteNode | null { 493 if (!routes?.length) { 494 return null; 495 } 496 497 if (hasCustomRootLayoutNode(routes)) { 498 return routes[0]; 499 } 500 501 return { 502 loadRoute: () => ({ 503 default: (require('./views/Navigator') as typeof import('./views/Navigator')) 504 .DefaultNavigator, 505 }), 506 // Generate a fake file name for the directory 507 contextKey: './_layout.tsx', 508 route: '', 509 generated: true, 510 dynamic: null, 511 children: routes, 512 }; 513} 514