Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 119 additions & 38 deletions packages/router-generator/src/filesystem/physical/getRouteNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'node:path'
import * as fsp from 'node:fs/promises'
import {
determineInitialRoutePath,
hasEscapedLeadingUnderscore,
removeExt,
replaceBackslash,
routePathToVariable,
Expand Down Expand Up @@ -153,7 +154,7 @@ export async function getRouteNodes(
throw new Error(errorMessage)
}

const meta = getRouteMeta(routePath, config)
const meta = getRouteMeta(routePath, originalRoutePath, config)
const variableName = meta.variableName
let routeType: FsRouteType = meta.fsRouteType

Expand All @@ -164,7 +165,14 @@ export async function getRouteNodes(

// 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)) {
if (
isValidPathlessLayoutRoute(
routePath,
originalRoutePath,
routeType,
config,
)
) {
routeType = 'pathless_layout'
}

Expand All @@ -189,37 +197,64 @@ export async function getRouteNodes(
})
}

routePath = routePath.replace(
new RegExp(
`/(component|errorComponent|notFoundComponent|pendingComponent|loader|${config.routeToken}|lazy)$`,
),
'',
)

originalRoutePath = originalRoutePath.replace(
new RegExp(
`/(component|errorComponent|notFoundComponent|pendingComponent|loader|${config.routeToken}|lazy)$`,
),
'',
)
// Get the last segment of originalRoutePath to check for escaping
const originalSegments = originalRoutePath.split('/').filter(Boolean)
const lastOriginalSegmentForSuffix =
originalSegments[originalSegments.length - 1] || ''

// List of special suffixes that can be escaped
const specialSuffixes = [
'component',
'errorComponent',
'notFoundComponent',
'pendingComponent',
'loader',
config.routeToken,
'lazy',
]

// Only strip the suffix if it wasn't escaped (not wrapped in brackets)
const suffixToStrip = specialSuffixes.find((suffix) => {
const endsWithSuffix = routePath.endsWith(`/${suffix}`)
const isEscaped = lastOriginalSegmentForSuffix === `[${suffix}]`
return endsWithSuffix && !isEscaped
})

if (routePath === config.indexToken) {
routePath = '/'
if (suffixToStrip) {
routePath = routePath.replace(new RegExp(`/${suffixToStrip}$`), '')
originalRoutePath = originalRoutePath.replace(
new RegExp(`/${suffixToStrip}$`),
'',
)
}

if (originalRoutePath === config.indexToken) {
originalRoutePath = '/'
// Check if the index token should be treated specially or as a literal path
// If it's escaped (wrapped in brackets in originalRoutePath), it should be literal
const lastOriginalSegment =
originalRoutePath.split('/').filter(Boolean).pop() || ''
const isIndexEscaped =
lastOriginalSegment === `[${config.indexToken}]`

if (!isIndexEscaped) {
if (routePath === config.indexToken) {
routePath = '/'
}

if (originalRoutePath === config.indexToken) {
originalRoutePath = '/'
}

routePath =
routePath.replace(new RegExp(`/${config.indexToken}$`), '/') ||
'/'

originalRoutePath =
originalRoutePath.replace(
new RegExp(`/${config.indexToken}$`),
'/',
) || '/'
}

routePath =
routePath.replace(new RegExp(`/${config.indexToken}$`), '/') || '/'

originalRoutePath =
originalRoutePath.replace(
new RegExp(`/${config.indexToken}$`),
'/',
) || '/'

routeNodes.push({
filePath,
fullPath,
Expand Down Expand Up @@ -266,12 +301,14 @@ export async function getRouteNodes(
/**
* Determines the metadata for a given route path based on the provided configuration.
*
* @param routePath - The determined initial routePath.
* @param routePath - The determined initial routePath (with brackets removed).
* @param originalRoutePath - The original route path (may contain brackets for escaped content).
* @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,
originalRoutePath: string,
config: Pick<Config, 'routeToken' | 'indexToken'>,
): {
// `__root` is can be more easily determined by filtering down to routePath === /${rootPathId}
Expand All @@ -292,25 +329,50 @@ export function getRouteMeta(
} {
let fsRouteType: FsRouteType = 'static'

if (routePath.endsWith(`/${config.routeToken}`)) {
// Get the last segment from the original path to check for escaping
const originalSegments = originalRoutePath.split('/').filter(Boolean)
const lastOriginalSegment =
originalSegments[originalSegments.length - 1] || ''

// Helper to check if a specific suffix is escaped
const isSuffixEscaped = (suffix: string): boolean => {
return lastOriginalSegment === `[${suffix}]`
}

if (
routePath.endsWith(`/${config.routeToken}`) &&
!isSuffixEscaped(config.routeToken)
) {
// layout routes, i.e `/foo/route.tsx` or `/foo/_layout/route.tsx`
fsRouteType = 'layout'
} else if (routePath.endsWith('/lazy')) {
} else if (routePath.endsWith('/lazy') && !isSuffixEscaped('lazy')) {
// lazy routes, i.e. `/foo.lazy.tsx`
fsRouteType = 'lazy'
} else if (routePath.endsWith('/loader')) {
} else if (routePath.endsWith('/loader') && !isSuffixEscaped('loader')) {
// loader routes, i.e. `/foo.loader.tsx`
fsRouteType = 'loader'
} else if (routePath.endsWith('/component')) {
} else if (
routePath.endsWith('/component') &&
!isSuffixEscaped('component')
) {
// component routes, i.e. `/foo.component.tsx`
fsRouteType = 'component'
} else if (routePath.endsWith('/pendingComponent')) {
} else if (
routePath.endsWith('/pendingComponent') &&
!isSuffixEscaped('pendingComponent')
) {
// pending component routes, i.e. `/foo.pendingComponent.tsx`
fsRouteType = 'pendingComponent'
} else if (routePath.endsWith('/errorComponent')) {
} else if (
routePath.endsWith('/errorComponent') &&
!isSuffixEscaped('errorComponent')
) {
// error component routes, i.e. `/foo.errorComponent.tsx`
fsRouteType = 'errorComponent'
} else if (routePath.endsWith('/notFoundComponent')) {
} else if (
routePath.endsWith('/notFoundComponent') &&
!isSuffixEscaped('notFoundComponent')
) {
// not found component routes, i.e. `/foo.notFoundComponent.tsx`
fsRouteType = 'notFoundComponent'
}
Expand All @@ -323,11 +385,14 @@ export function getRouteMeta(
/**
* 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 originalRoutePath Original route path with brackets for escaped content
* @param routeType The route type determined from file extension
* @param config The `router-generator` configuration object
* @returns Boolean indicating if the route is a pathless layout route
*/
function isValidPathlessLayoutRoute(
normalizedRoutePath: string,
originalRoutePath: string,
routeType: FsRouteType,
config: Pick<Config, 'routeToken' | 'indexToken'>,
): boolean {
Expand All @@ -336,13 +401,18 @@ function isValidPathlessLayoutRoute(
}

const segments = normalizedRoutePath.split('/').filter(Boolean)
const originalSegments = originalRoutePath.split('/').filter(Boolean)

if (segments.length === 0) {
return false
}

const lastRouteSegment = segments[segments.length - 1]!
const lastOriginalSegment =
originalSegments[originalSegments.length - 1] || ''
const secondToLastRouteSegment = segments[segments.length - 2]
const secondToLastOriginalSegment =
originalSegments[originalSegments.length - 2]

// If segment === __root, then exit as false
if (lastRouteSegment === rootPathId) {
Expand All @@ -352,14 +422,25 @@ function isValidPathlessLayoutRoute(
// 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
// But if the underscore is escaped, it's not a pathless layout
if (
lastRouteSegment === config.routeToken &&
typeof secondToLastRouteSegment === 'string'
typeof secondToLastRouteSegment === 'string' &&
typeof secondToLastOriginalSegment === 'string'
) {
// Check if the underscore is escaped
if (hasEscapedLeadingUnderscore(secondToLastOriginalSegment)) {
return false
}
return secondToLastRouteSegment.startsWith('_')
}

// Segment starts with _
// Segment starts with _ but check if it's escaped
// If the original segment has [_] at the start, the underscore is escaped and it's not a pathless layout
if (hasEscapedLeadingUnderscore(lastOriginalSegment)) {
return false
}

return (
lastRouteSegment !== config.indexToken &&
lastRouteSegment !== config.routeToken &&
Expand Down
38 changes: 33 additions & 5 deletions packages/router-generator/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ import {
getResolvedRouteNodeVariableName,
hasParentRoute,
isRouteNodeValidForAugmentation,
isSegmentPathless,
mergeImportDeclarations,
multiSortBy,
removeExt,
removeGroups,
removeLastSegmentFromPath,
removeLayoutSegments,
removeLayoutSegmentsWithEscape,
removeTrailingSlash,
removeUnderscores,
removeUnderscoresWithEscape,
replaceBackslash,
trimPathLeft,
} from './utils'
Expand Down Expand Up @@ -1363,16 +1364,30 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
node.path = determineNodePath(node)

const trimmedPath = trimPathLeft(node.path ?? '')
const trimmedOriginalPath = trimPathLeft(
node.originalRoutePath?.replace(
node.parent?.originalRoutePath ?? '',
'',
) ?? '',
)

const split = trimmedPath.split('/')
const originalSplit = trimmedOriginalPath.split('/')
const lastRouteSegment = split[split.length - 1] ?? trimmedPath
const lastOriginalSegment =
originalSplit[originalSplit.length - 1] ?? trimmedOriginalPath

// A segment is non-path if it starts with underscore AND the underscore is not escaped
node.isNonPath =
lastRouteSegment.startsWith('_') ||
isSegmentPathless(lastRouteSegment, lastOriginalSegment) ||
split.every((part) => this.routeGroupPatternRegex.test(part))

// Use escape-aware functions to compute cleanedPath
node.cleanedPath = removeGroups(
removeUnderscores(removeLayoutSegments(node.path)) ?? '',
removeUnderscoresWithEscape(
removeLayoutSegmentsWithEscape(node.path, node.originalRoutePath),
node.originalRoutePath,
),
)

if (
Expand Down Expand Up @@ -1431,6 +1446,8 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
if (!node.isVirtual && isPathlessLayoutWithPath) {
const immediateParentPath =
removeLastSegmentFromPath(node.routePath) || '/'
const immediateParentOriginalPath =
removeLastSegmentFromPath(node.originalRoutePath) || '/'
let searchPath = immediateParentPath

// Find nearest real (non-virtual, non-index) parent
Expand All @@ -1442,8 +1459,19 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
node.routePath?.replace(candidate.routePath ?? '', '') || '/'
const pathRelativeToParent =
immediateParentPath.replace(candidate.routePath ?? '', '') || '/'
const originalPathRelativeToParent =
immediateParentOriginalPath.replace(
candidate.originalRoutePath ?? '',
'',
) || '/'
node.cleanedPath = removeGroups(
removeUnderscores(removeLayoutSegments(pathRelativeToParent)) ?? '',
removeUnderscoresWithEscape(
removeLayoutSegmentsWithEscape(
pathRelativeToParent,
originalPathRelativeToParent,
),
originalPathRelativeToParent,
),
)
break
}
Expand Down
Loading
Loading