@tanstack/router-generator
Version:
Modern and scalable routing for React applications
309 lines (267 loc) • 9.86 kB
text/typescript
import path from 'node:path'
import * as fsp from 'node:fs/promises'
import {
determineInitialRoutePath,
removeExt,
replaceBackslash,
routePathToVariable,
} from '../../utils'
import { getRouteNodes as getRouteNodesVirtual } from '../virtual/getRouteNodes'
import { loadConfigFile } from '../virtual/loadConfigFile'
import { logging } from '../../logger'
import { rootPathId } from './rootPathId'
import type {
VirtualRootRoute,
VirtualRouteSubtreeConfig,
} from '@tanstack/virtual-file-routes'
import type { FsRouteType, GetRouteNodesResult, RouteNode } from '../../types'
import type { Config } from '../../config'
const disallowedRouteGroupConfiguration = /\(([^)]+)\).(ts|js|tsx|jsx)/
const virtualConfigFileRegExp = /__virtual\.[mc]?[jt]s$/
export function isVirtualConfigFile(fileName: string): boolean {
return virtualConfigFileRegExp.test(fileName)
}
export async function getRouteNodes(
config: Pick<
Config,
| 'routesDirectory'
| 'routeFilePrefix'
| 'routeFileIgnorePrefix'
| 'routeFileIgnorePattern'
| 'disableLogging'
| 'routeToken'
| 'indexToken'
>,
root: string,
): Promise<GetRouteNodesResult> {
const { routeFilePrefix, routeFileIgnorePrefix, routeFileIgnorePattern } =
config
const logger = logging({ disabled: config.disableLogging })
const routeFileIgnoreRegExp = new RegExp(routeFileIgnorePattern ?? '', 'g')
const routeNodes: Array<RouteNode> = []
const allPhysicalDirectories: Array<string> = []
async function recurse(dir: string) {
const fullDir = path.resolve(config.routesDirectory, dir)
let dirList = await fsp.readdir(fullDir, { withFileTypes: true })
dirList = dirList.filter((d) => {
if (
d.name.startsWith('.') ||
(routeFileIgnorePrefix && d.name.startsWith(routeFileIgnorePrefix))
) {
return false
}
if (routeFilePrefix) {
return d.name.startsWith(routeFilePrefix)
}
if (routeFileIgnorePattern) {
return !d.name.match(routeFileIgnoreRegExp)
}
return true
})
const virtualConfigFile = dirList.find((dirent) => {
return dirent.isFile() && isVirtualConfigFile(dirent.name)
})
if (virtualConfigFile !== undefined) {
const virtualRouteConfigExport = await loadConfigFile(
path.resolve(fullDir, virtualConfigFile.name),
)
let virtualRouteSubtreeConfig: VirtualRouteSubtreeConfig
if (typeof virtualRouteConfigExport.default === 'function') {
virtualRouteSubtreeConfig = await virtualRouteConfigExport.default()
} else {
virtualRouteSubtreeConfig = virtualRouteConfigExport.default
}
const dummyRoot: VirtualRootRoute = {
type: 'root',
file: '',
children: virtualRouteSubtreeConfig,
}
const { routeNodes: virtualRouteNodes, physicalDirectories } =
await getRouteNodesVirtual(
{
...config,
routesDirectory: fullDir,
virtualRouteConfig: dummyRoot,
},
root,
)
allPhysicalDirectories.push(...physicalDirectories)
virtualRouteNodes.forEach((node) => {
const filePath = replaceBackslash(path.join(dir, node.filePath))
const routePath = `/${dir}${node.routePath}`
node.variableName = routePathToVariable(
`${dir}/${removeExt(node.filePath)}`,
)
node.routePath = routePath
node.filePath = filePath
})
routeNodes.push(...virtualRouteNodes)
return
}
await Promise.all(
dirList.map(async (dirent) => {
const fullPath = replaceBackslash(path.join(fullDir, dirent.name))
const relativePath = path.posix.join(dir, dirent.name)
if (dirent.isDirectory()) {
await recurse(relativePath)
} else if (fullPath.match(/\.(tsx|ts|jsx|js)$/)) {
const filePath = replaceBackslash(path.join(dir, dirent.name))
const filePathNoExt = removeExt(filePath)
let routePath = determineInitialRoutePath(filePathNoExt)
if (routeFilePrefix) {
routePath = routePath.replaceAll(routeFilePrefix, '')
}
if (disallowedRouteGroupConfiguration.test(dirent.name)) {
const errorMessage = `A route configuration for a route group was found at \`${filePath}\`. This is not supported. Did you mean to use a layout/pathless route instead?`
logger.error(`ERROR: ${errorMessage}`)
throw new Error(errorMessage)
}
const meta = getRouteMeta(routePath, config)
const variableName = meta.variableName
let routeType: FsRouteType = meta.fsRouteType
if (routeType === 'lazy') {
routePath = routePath.replace(/\/lazy$/, '')
}
// this check needs to happen after the lazy route has been cleaned up
// since the routePath is used to determine if a route is pathless
if (isValidPathlessLayoutRoute(routePath, routeType, config)) {
routeType = 'pathless_layout'
}
;(
[
['component', 'component'],
['errorComponent', 'errorComponent'],
['pendingComponent', 'pendingComponent'],
['loader', 'loader'],
] satisfies Array<[FsRouteType, string]>
).forEach(([matcher, type]) => {
if (routeType === matcher) {
logger.warn(
`WARNING: The \`.${type}.tsx\` suffix used for the ${filePath} file is deprecated. Use the new \`.lazy.tsx\` suffix instead.`,
)
}
})
routePath = routePath.replace(
new RegExp(
`/(component|errorComponent|pendingComponent|loader|${config.routeToken}|lazy)$`,
),
'',
)
if (routePath === config.indexToken) {
routePath = '/'
}
routePath =
routePath.replace(new RegExp(`/${config.indexToken}$`), '/') || '/'
routeNodes.push({
filePath,
fullPath,
routePath,
variableName,
_fsRouteType: routeType,
})
}
}),
)
return routeNodes
}
await recurse('./')
const rootRouteNode = routeNodes.find((d) => d.routePath === `/${rootPathId}`)
if (rootRouteNode) {
rootRouteNode._fsRouteType = '__root'
rootRouteNode.variableName = 'root'
}
return {
rootRouteNode,
routeNodes,
physicalDirectories: allPhysicalDirectories,
}
}
/**
* Determines the metadata for a given route path based on the provided configuration.
*
* @param routePath - The determined initial routePath.
* @param config - The user configuration object.
* @returns An object containing the type of the route and the variable name derived from the route path.
*/
export function getRouteMeta(
routePath: string,
config: Pick<Config, 'routeToken' | 'indexToken'>,
): {
// `__root` is can be more easily determined by filtering down to routePath === /${rootPathId}
// `pathless` is needs to determined after `lazy` has been cleaned up from the routePath
fsRouteType: Extract<
FsRouteType,
| 'static'
| 'layout'
| 'api'
| 'lazy'
| 'loader'
| 'component'
| 'pendingComponent'
| 'errorComponent'
>
variableName: string
} {
let fsRouteType: FsRouteType = 'static'
if (routePath.endsWith(`/${config.routeToken}`)) {
// layout routes, i.e `/foo/route.tsx` or `/foo/_layout/route.tsx`
fsRouteType = 'layout'
} else if (routePath.endsWith('/lazy')) {
// lazy routes, i.e. `/foo.lazy.tsx`
fsRouteType = 'lazy'
} else if (routePath.endsWith('/loader')) {
// loader routes, i.e. `/foo.loader.tsx`
fsRouteType = 'loader'
} else if (routePath.endsWith('/component')) {
// component routes, i.e. `/foo.component.tsx`
fsRouteType = 'component'
} else if (routePath.endsWith('/pendingComponent')) {
// pending component routes, i.e. `/foo.pendingComponent.tsx`
fsRouteType = 'pendingComponent'
} else if (routePath.endsWith('/errorComponent')) {
// error component routes, i.e. `/foo.errorComponent.tsx`
fsRouteType = 'errorComponent'
}
const variableName = routePathToVariable(routePath)
return { fsRouteType, variableName }
}
/**
* Used to validate if a route is a pathless layout route
* @param normalizedRoutePath Normalized route path, i.e `/foo/_layout/route.tsx` and `/foo._layout.route.tsx` to `/foo/_layout/route`
* @param config The `router-generator` configuration object
* @returns Boolean indicating if the route is a pathless layout route
*/
function isValidPathlessLayoutRoute(
normalizedRoutePath: string,
routeType: FsRouteType,
config: Pick<Config, 'routeToken' | 'indexToken'>,
): boolean {
if (routeType === 'lazy') {
return false
}
const segments = normalizedRoutePath.split('/').filter(Boolean)
if (segments.length === 0) {
return false
}
const lastRouteSegment = segments[segments.length - 1]!
const secondToLastRouteSegment = segments[segments.length - 2]
// If segment === __root, then exit as false
if (lastRouteSegment === rootPathId) {
return false
}
// If segment === config.routeToken and secondToLastSegment is a string that starts with _, then exit as true
// Since the route is actually a configuration route for a layout/pathless route
// i.e. /foo/_layout/route.tsx === /foo/_layout.tsx
if (
lastRouteSegment === config.routeToken &&
typeof secondToLastRouteSegment === 'string'
) {
return secondToLastRouteSegment.startsWith('_')
}
// Segment starts with _
return (
lastRouteSegment !== config.indexToken &&
lastRouteSegment !== config.routeToken &&
lastRouteSegment.startsWith('_')
)
}