UNPKG

expo-router

Version:

Expo Router is a file-based router for React Native and web applications.

654 lines (652 loc) • 29.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); 0 && (module.exports = { extrapolateGroups: null, generateDynamic: null, getRoutes: null }); function _export(target, all) { for(var name in all)Object.defineProperty(target, name, { enumerable: true, get: Object.getOwnPropertyDescriptor(all, name).get }); } _export(exports, { get extrapolateGroups () { return extrapolateGroups; }, get generateDynamic () { return generateDynamic; }, get getRoutes () { return getRoutes; } }); const _matchers = require("./matchers"); const _routeNode = require("./routeNode"); const _url = require("./utils/url"); function getRoutes(contextModule, options) { const middleware = getMiddleware(contextModule, options); const directoryTree = getDirectoryTree(contextModule, options); // If there are no routes if (!directoryTree) { return null; } const rootNode = flattenDirectoryTreeToRoutes(directoryTree, options); const importMode = options.importMode || process.env.EXPO_ROUTER_IMPORT_MODE; if (process.env.NODE_ENV === 'development' && importMode === 'sync' && !options.ignoreRequireErrors) { validateRouteTreeExports(rootNode); } if (middleware) { rootNode.middleware = middleware; } if (!options.ignoreEntryPoints) { crawlAndAppendInitialRoutesAndEntryFiles(rootNode, options); } return rootNode; } /** * Given a RequireContext, return the middleware node if one is found. If more than one middleware file is found, an error is thrown. */ function getMiddleware(contextModule, options) { const allMiddlewareFiles = contextModule.keys().filter((key)=>key.includes('+middleware')); const isValidMiddleware = (key)=>/^\.\/\+middleware\.[tj]sx?$/.test(key); const rootMiddlewareFiles = allMiddlewareFiles.filter(isValidMiddleware); const nonRootMiddleware = allMiddlewareFiles.filter((file)=>!rootMiddlewareFiles.includes(file)); if (nonRootMiddleware.length > 0) { throw new Error(`The middleware file can only be placed at the root level. Remove the following files: ${nonRootMiddleware.join(', ')}`); } if (rootMiddlewareFiles.length === 0) { return null; } // In development, throw an error if there are multiple root-level middleware files if (rootMiddlewareFiles.length > 1) { if (process.env.NODE_ENV !== 'production') { throw new Error(`Only one middleware file is allowed. Keep one of the conflicting files: ${rootMiddlewareFiles.map((p)=>`"${p}"`).join(' or ')}`); } } const middlewareFilePath = rootMiddlewareFiles[0]; const middleware = { loadRoute () { if (options.ignoreRequireErrors) { try { return contextModule(middlewareFilePath); } catch { return {}; } } else { return contextModule(middlewareFilePath); } }, contextKey: middlewareFilePath }; if (options.internal_stripLoadRoute) { delete middleware.loadRoute; } return middleware; } /** * Converts the RequireContext keys (file paths) into a directory tree. */ function getDirectoryTree(contextModule, options) { const importMode = options.importMode || process.env.EXPO_ROUTER_IMPORT_MODE; const ignoreList = [ /^\.\/\+(html|native-intent)\.[tj]sx?$/ ]; // Ignore the top level ./+html file if (options.ignore) { ignoreList.push(...options.ignore); } if (!options.preserveApiRoutes) { ignoreList.push(/\+api$/, /\+api\.[tj]sx?$/); } // Always ignore middleware files in regular route processing ignoreList.push(/\+middleware$/, /\+middleware\.[tj]sx?$/); const rootDirectory = { files: new Map(), subdirectories: new Map() }; let hasRoutes = false; let isValid = false; const contextKeys = contextModule.keys(); const redirects = {}; const rewrites = {}; let validRedirectDestinations; const getValidDestinations = ()=>{ // Loop over contexts once and cache the valid destinations validRedirectDestinations ??= contextKeys.map((key)=>{ return { contextKey: key, nameWithoutInvisible: getNameWithoutInvisibleSegmentsFromRedirectPath((0, _matchers.removeSupportedExtensions)(key)) }; }); return validRedirectDestinations; }; // If we are keeping redirects as valid routes, then we need to add them to the contextKeys // This is useful for generating a sitemap with redirects, or static site generation that includes redirects if (options.preserveRedirectAndRewrites) { if (options.redirects) { for (const redirect of options.redirects){ const sourceContextKey = getSourceContextKeyFromRedirectSource(redirect.source); const sourceName = getNameFromRedirectPath(redirect.source); const isExternalRedirect = (0, _url.shouldLinkExternally)(redirect.destination); const targetDestinationName = isExternalRedirect ? redirect.destination : getNameWithoutInvisibleSegmentsFromRedirectPath(redirect.destination); if (ignoreList.some((regex)=>regex.test(sourceContextKey))) { continue; } const validDestination = isExternalRedirect ? undefined : getValidDestinations().find((key)=>key.nameWithoutInvisible === targetDestinationName); const destination = isExternalRedirect ? targetDestinationName : validDestination?.nameWithoutInvisible; const destinationContextKey = isExternalRedirect ? targetDestinationName : validDestination?.contextKey; if (!destinationContextKey || destination === undefined) { /* * Only throw the error when we are preserving the api routes * When doing a static export, API routes will not exist so the redirect destination may not exist. * The desired behavior for this error is to warn the user when running `expo start`, so its ok if * `expo export` swallows this error. */ if (options.preserveApiRoutes) { throw new Error(`Redirect destination "${redirect.destination}" does not exist.`); } continue; } contextKeys.push(sourceContextKey); redirects[sourceName] = { source: sourceName, destination, destinationContextKey, permanent: Boolean(redirect.permanent), external: isExternalRedirect, methods: redirect.methods }; } } if (options.rewrites) { for (const rewrite of options.rewrites){ const sourceContextKey = getSourceContextKeyFromRedirectSource(rewrite.source); const sourceName = getNameFromRedirectPath(rewrite.source); // We check to see if the context key is already known so that we don't create a rewrite for // a route that already exists on disk const isSourceContextKeyAlreadyKnown = contextKeys.includes(sourceContextKey); const targetDestinationName = isSourceContextKeyAlreadyKnown ? getNameFromRedirectPath(rewrite.destination) : getNameWithoutInvisibleSegmentsFromRedirectPath(rewrite.destination); if (ignoreList.some((regex)=>regex.test(sourceContextKey))) { continue; } const validDestination = getValidDestinations().find((key)=>key.nameWithoutInvisible === targetDestinationName); const destination = validDestination?.nameWithoutInvisible; const destinationContextKey = validDestination?.contextKey; if (!destinationContextKey || destination === undefined) { /* * Only throw the error when we are preserving the api routes * When doing a static export, API routes will not exist so the redirect destination may not exist. * The desired behavior for this error is to warn the user when running `expo start`, so its ok if * `expo export` swallows this error. */ if (options.preserveApiRoutes) { throw new Error(`Rewrite destination "${rewrite.destination}" does not exist.`); } continue; } contextKeys.push(sourceContextKey); rewrites[sourceName] = { source: sourceName, destination, destinationContextKey, methods: rewrite.methods }; } } } const processedRedirectsRewrites = new Set(); for (const filePath of contextKeys){ if (ignoreList.some((regex)=>regex.test(filePath))) { continue; } isValid = true; const meta = getFileMeta(filePath, options, redirects, rewrites); // This is a file that should be ignored. e.g maybe it has an invalid platform? if (meta.specificity < 0) { continue; } let node = { type: meta.isApi ? 'api' : meta.isLayout ? 'layout' : 'route', loadRoute () { let routeModule; if (options.ignoreRequireErrors) { try { routeModule = contextModule(filePath); } catch { routeModule = {}; } } else { routeModule = contextModule(filePath); } // See: expo/src/async-require/asyncRequireModule.ts // The "lazy" async require function returns a thenable that may carry // a raw `_result` value that's either a promise or the synchronously resolved module if (importMode === 'lazy' || importMode === 'lazy-once') { routeModule = '_result' in routeModule && routeModule._result != null ? routeModule._result : routeModule; } if (process.env.NODE_ENV === 'development' && importMode === 'sync') { // In development mode, when async routes are disabled, add some extra error handling to improve the developer experience. // This can be useful when you accidentally use an async function in a route file for the default export. if (routeModule instanceof Promise) { throw new Error(`Route "${filePath}" cannot be a promise when async routes are disabled.`); } const defaultExport = routeModule?.default; if (defaultExport instanceof Promise) { throw new Error(`The default export from route "${filePath}" is a promise. Ensure the React Component does not use async or promises.`); } // check if default is an async function without invoking it if (defaultExport instanceof Function && // This only works on web because Hermes support async functions so we have to transform them out. defaultExport.constructor.name === 'AsyncFunction') { throw new Error(`The default export from route "${filePath}" is an async function. Ensure the React Component does not use async or promises.`); } // Validate loader export in development const loaderExport = routeModule?.loader; if (loaderExport && typeof loaderExport !== 'function') { throw new Error(`Route "${filePath}" exports a loader that is not a function.`); } const metadataExport = routeModule?.generateMetadata; if (metadataExport && typeof metadataExport !== 'function') { throw new Error(`Route "${filePath}" exports generateMetadata that is not a function.`); } } return routeModule; }, contextKey: filePath, route: '', dynamic: null, children: [] }; if (meta.isRedirect) { if (processedRedirectsRewrites.has(meta.route)) { continue; } const redirect = redirects[meta.route]; node.destinationContextKey = redirect.destinationContextKey; node.permanent = redirect.permanent; node.generated = true; if (node.type === 'route') { node = options.getSystemRoute({ type: 'redirect', route: redirect.destination, defaults: node, redirectConfig: redirect }); } if (redirect.methods) { node.methods = redirect.methods; } node.type = 'redirect'; processedRedirectsRewrites.add(meta.route); } if (meta.isRewrite) { if (processedRedirectsRewrites.has(meta.route)) { continue; } const rewrite = rewrites[meta.route]; node.destinationContextKey = rewrite.destinationContextKey; node.generated = true; if (node.type === 'route') { node = options.getSystemRoute({ type: 'rewrite', route: rewrite.destination, defaults: node, rewriteConfig: rewrite }); } if (rewrite.methods) { node.methods = rewrite.methods; } node.type = 'rewrite'; processedRedirectsRewrites.add(meta.route); } /** * A single filepath may be extrapolated into multiple routes if it contains array syntax. * Another way to thinking about is that a filepath node is present in multiple leaves of the directory tree. */ for (const route of extrapolateGroups(meta.route)){ // Traverse the directory tree to its leaf node, creating any missing directories along the way const subdirectoryParts = route.split('/').slice(0, -1); // Start at the root directory and traverse the path to the leaf directory let directory = rootDirectory; for (const part of subdirectoryParts){ let subDirectory = directory.subdirectories.get(part); // Create any missing subdirectories if (!subDirectory) { subDirectory = { files: new Map(), subdirectories: new Map() }; directory.subdirectories.set(part, subDirectory); } directory = subDirectory; } // Clone the node for this route node = { ...node, route }; if (meta.isLayout) { directory.layout ??= []; const existing = directory.layout[meta.specificity]; if (existing) { // In production, use the first route found if (process.env.NODE_ENV !== 'production') { throw new Error(`The layouts "${filePath}" and "${existing.contextKey}" conflict on the route "/${route}". Remove or rename one of these files.`); } } else { node = getLayoutNode(node); directory.layout[meta.specificity] = node; } } else if (meta.isApi) { const fileKey = `${route}+api`; let nodes = directory.files.get(fileKey); if (!nodes) { nodes = []; directory.files.set(fileKey, nodes); } // API Routes have no specificity, they are always the first node const existing = nodes[0]; if (existing) { // In production, use the first route found if (process.env.NODE_ENV !== 'production') { throw new Error(`The API route file "${filePath}" and "${existing.contextKey}" conflict on the route "/${route}". Remove or rename one of these files.`); } } else { nodes[0] = node; } } else { let nodes = directory.files.get(route); if (!nodes) { nodes = []; directory.files.set(route, nodes); } /** * If there is an existing node with the same specificity, then we have a conflict. * NOTE(Platform Routes): * We cannot check for specificity conflicts here, as we haven't processed all the context keys yet! * This will be checked during hoisting, as well as enforcing that all routes have a non-platform route. */ const existing = nodes[meta.specificity]; if (existing) { // In production, use the first route found if (process.env.NODE_ENV !== 'production') { throw new Error(`The route files "${filePath}" and "${existing.contextKey}" conflict on the route "/${route}". Remove or rename one of these files.`); } } else { hasRoutes ||= true; nodes[meta.specificity] = node; } } } } // If there are no routes/layouts then we should display the tutorial. if (!isValid) { return null; } /** * If there are no top-level _layout, add a default _layout * While this is a generated route, it will still be generated even if skipGenerated is true. */ if (!rootDirectory.layout) { rootDirectory.layout = [ options.getSystemRoute({ type: 'layout', route: '' }) ]; } // Only include the sitemap if there are routes. if (!options.skipGenerated) { if (hasRoutes && options.sitemap !== false) { appendSitemapRoute(rootDirectory, options); } if (options.notFound !== false) { appendNotFoundRoute(rootDirectory, options); } } return rootDirectory; } function getNameFromRedirectPath(path) { // Removing only the filesystem extensions, to be able to handle +api, +html return (0, _matchers.removeFileSystemExtensions)((0, _matchers.removeFileSystemDots)(path))// Remove the leading `/` .replace(/^\//, ''); } function getNameWithoutInvisibleSegmentsFromRedirectPath(path) { return (0, _matchers.stripInvisibleSegmentsFromPath)(getNameFromRedirectPath(path)); } // Creates fake context key for redirects and rewrites function getSourceContextKeyFromRedirectSource(source) { const name = getNameFromRedirectPath(source); const prefix = './'; const suffix = /\.[tj]sx?$/.test(name) ? '' : '.js'; // Ensure it has a file extension return `${prefix}${name}${suffix}`; } /** * Flatten the directory tree into routes, hoisting routes to the nearest _layout. */ function flattenDirectoryTreeToRoutes(directory, options, /* The nearest _layout file in the directory tree */ layout, /* Route names are relative to their layout */ pathToRemove = '') { /** * This directory has a _layout file so it becomes the new target for hoisting routes. */ if (directory.layout) { const previousLayout = layout; layout = getMostSpecific(directory.layout); // Add the new layout as a child of its parent if (previousLayout) { previousLayout.children.push(layout); } if (options.internal_stripLoadRoute) { delete layout.loadRoute; } // `route` is the absolute pathname. We need to make this relative to the last _layout const newRoute = layout.route.replace(pathToRemove, ''); pathToRemove = layout.route ? `${layout.route}/` : ''; // Now update this layout with the new relative route and dynamic conventions layout.route = newRoute; layout.dynamic = generateDynamic(layout.contextKey.slice(0)); } // This should never occur as there will always be a root layout, but it makes the type system happy if (!layout) throw new Error('Expo Router Internal Error: No nearest layout'); for (const routes of directory.files.values()){ const routeNode = getMostSpecific(routes); // `route` is the absolute pathname. We need to make this relative to the nearest layout routeNode.route = routeNode.route.replace(pathToRemove, ''); routeNode.dynamic = generateDynamic(routeNode.route); if (options.internal_stripLoadRoute) { delete routeNode.loadRoute; } layout.children.push(routeNode); } // Recursively flatten the subdirectories for (const child of directory.subdirectories.values()){ flattenDirectoryTreeToRoutes(child, options, layout, pathToRemove); } return layout; } function validateRouteTreeExports(node) { if (process.env.NODE_ENV !== 'development' || node.type === 'api') { return; } function runtimeValidateRouteNode(node) { const routeItem = node.loadRoute(); // Have a warning for nullish ex const route = routeItem?.default; if (route == null) { // Do not throw an error since a user may just be creating a new route. console.warn(`Route "${node.contextKey}" is missing the required default export. Ensure a React component is exported as default.`); } if ([ 'boolean', 'number', 'string' ].includes(typeof route)) { throw new Error(`The default export from route "${node.contextKey}" is an unsupported type: "${typeof route}". Only React Components are supported as default exports from route files.`); } } runtimeValidateRouteNode(node); for (const child of node.children){ validateRouteTreeExports(child); } } function getFileMeta(originalKey, options, redirects, rewrites) { // Remove the leading `./` // NOTE(@hassankhan): Why not use `getNameFromFilePath()` here? const key = (0, _matchers.removeSupportedExtensions)((0, _matchers.removeFileSystemDots)(originalKey)); let route = key; const parts = (0, _matchers.removeFileSystemDots)(originalKey).split('/'); const filename = parts[parts.length - 1]; const filenameWithoutFileExtension = (0, _matchers.removeSupportedExtensions)(filename); const platformExtension = (0, _matchers.getPlatformFromFilePath)(filename); const filenameWithoutExtensions = platformExtension ? filenameWithoutFileExtension.slice(0, -(platformExtension.length + 1)) : filenameWithoutFileExtension; const isLayout = filenameWithoutExtensions === '_layout'; const isApi = originalKey.match(/\+api\.(\w+\.)?[jt]sx?$/); if (filenameWithoutExtensions.startsWith('(') && filenameWithoutExtensions.endsWith(')')) { throw new Error(`Invalid route ${originalKey}. Routes cannot end with '(group)' syntax`); } // Nested routes cannot start with the '+' character, except for the '+not-found' route if (!isApi && filename.startsWith('+') && filenameWithoutExtensions !== '+not-found') { const renamedRoute = [ ...parts.slice(0, -1), filename.slice(1) ].join('/'); throw new Error(`Invalid route ${originalKey}. Route nodes cannot start with the '+' character. "Rename it to ${renamedRoute}"`); } let specificity = 0; const hasPlatformExtension = platformExtension != null; const usePlatformRoutes = options.platformRoutes ?? true; if (hasPlatformExtension) { if (!usePlatformRoutes) { // If the user has disabled platform routes, then we should ignore this file specificity = -1; } else if (!options.platform) { // If we don't have a platform, then we should ignore this file // This used by typed routes, sitemap, etc specificity = -1; } else if (platformExtension === options.platform) { // If the platform extension is the same as the options.platform, then it is the most specific specificity = 2; } else if (platformExtension === 'native' && options.platform !== 'web') { // `native` is allow but isn't as specific as the platform specificity = 1; } else if (platformExtension !== options.platform) { // Somehow we have a platform extension that doesn't match the options.platform and it isn't native // This is an invalid file and we will ignore it specificity = -1; } if (isApi && specificity !== 0) { throw new Error(`API routes cannot have platform extensions. Remove '.${platformExtension}' from '${originalKey}'`); } route = route.slice(0, -(platformExtension.length + 1)); } return { route, specificity, isLayout, isApi, isRedirect: key in redirects, isRewrite: key in rewrites }; } function extrapolateGroups(key, keys = new Set()) { const match = (0, _matchers.matchArrayGroupName)(key); if (!match) { keys.add(key); return keys; } const groups = match.split(','); const groupsSet = new Set(groups); if (groupsSet.size !== groups.length) { throw new Error(`Array syntax cannot contain duplicate group name "${groups}" in "${key}".`); } if (groups.length === 1) { keys.add(key); return keys; } for (const group of groups){ extrapolateGroups(key.replace(match, group.trim()), keys); } return keys; } function generateDynamic(path) { const dynamic = path.split('/').map((part)=>{ if (part === '+not-found') { return { name: '+not-found', deep: true, notFound: true }; } return (0, _matchers.matchDynamicName)(part) ?? null; }).filter((part)=>!!part); return dynamic.length === 0 ? null : dynamic; } function appendSitemapRoute(directory, options) { if (!directory.files.has('_sitemap') && options.getSystemRoute) { directory.files.set('_sitemap', [ options.getSystemRoute({ type: 'route', route: '_sitemap' }) ]); } } function appendNotFoundRoute(directory, options) { if (!directory.files.has('+not-found') && options.getSystemRoute) { directory.files.set('+not-found', [ options.getSystemRoute({ type: 'route', route: '+not-found' }) ]); } } function getLayoutNode(node) { return { ...node, route: node.route.replace(/\/?_layout$/, ''), children: [], // `(a,b)/(c)/_layout` generates `(a)/(c)/_layout` and `(b)/(c)/_layout`, each with its own group. groupName: (0, _matchers.matchLastGroupName)(node.route) }; } function crawlAndAppendInitialRoutesAndEntryFiles(node, options, entryPoints = []) { if (node.type === 'route') { node.entryPoints = [ ...new Set([ ...entryPoints, node.contextKey ]) ]; } else if (node.type === 'redirect') { node.entryPoints = [ ...new Set([ ...entryPoints, node.destinationContextKey ]) ]; } else if (node.type === 'layout') { if (!node.children) { throw new Error(`Layout "${node.contextKey}" does not contain any child routes`); } // Every node below this layout will have it as an entryPoint entryPoints = [ ...entryPoints, node.contextKey ]; // Navigators can add anchor routes into the history, so they need to be included in the entryPoints. // An anchor can be a layout with its own anchor. // We may strip loadRoute during testing if (!options.internal_stripLoadRoute) { for(let anchor = (0, _routeNode.getValidInitialRoute)(node); anchor; anchor = (0, _routeNode.getValidInitialRoute)(anchor)){ entryPoints.push(anchor.contextKey); } } for (const child of node.children){ crawlAndAppendInitialRoutesAndEntryFiles(child, options, entryPoints); } } } function getMostSpecific(routes) { const route = routes[routes.length - 1]; if (!routes[0]) { throw new Error(`The file ${route.contextKey} does not have a fallback sibling file without a platform extension.`); } // This works even tho routes is holey array (e.g it might have index 0 and 2 but not 1) // `.length` includes the holes in its count return route; } //# sourceMappingURL=getRoutesCore.js.map