UNPKG

@tanstack/router-core

Version:

Modern and scalable routing for React applications

867 lines (754 loc) 26.3 kB
import { last } from './utils' import type { LRUCache } from './lru-cache' import type { MatchLocation } from './RouterProvider' import type { AnyPathParams } from './route' export const SEGMENT_TYPE_PATHNAME = 0 export const SEGMENT_TYPE_PARAM = 1 export const SEGMENT_TYPE_WILDCARD = 2 export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 export interface Segment { readonly type: | typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM readonly value: string readonly prefixSegment?: string readonly suffixSegment?: string // Indicates if there is a static segment after this required/optional param readonly hasStaticAfter?: boolean } export function joinPaths(paths: Array<string | undefined>) { return cleanPath( paths .filter((val) => { return val !== undefined }) .join('/'), ) } export function cleanPath(path: string) { // remove double slashes return path.replace(/\/{2,}/g, '/') } export function trimPathLeft(path: string) { return path === '/' ? path : path.replace(/^\/{1,}/, '') } export function trimPathRight(path: string) { return path === '/' ? path : path.replace(/\/{1,}$/, '') } export function trimPath(path: string) { return trimPathRight(trimPathLeft(path)) } export function removeTrailingSlash(value: string, basepath: string): string { if (value?.endsWith('/') && value !== '/' && value !== `${basepath}/`) { return value.slice(0, -1) } return value } // intended to only compare path name // see the usage in the isActive under useLinkProps // /sample/path1 = /sample/path1/ // /sample/path1/some <> /sample/path1 export function exactPathTest( pathName1: string, pathName2: string, basepath: string, ): boolean { return ( removeTrailingSlash(pathName1, basepath) === removeTrailingSlash(pathName2, basepath) ) } // When resolving relative paths, we treat all paths as if they are trailing slash // documents. All trailing slashes are removed after the path is resolved. // Here are a few examples: // // /a/b/c + ./d = /a/b/c/d // /a/b/c + ../d = /a/b/d // /a/b/c + ./d/ = /a/b/c/d // /a/b/c + ../d/ = /a/b/d // /a/b/c + ./ = /a/b/c // // Absolute paths that start with `/` short circuit the resolution process to the root // path. // // Here are some examples: // // /a/b/c + /d = /d // /a/b/c + /d/ = /d // /a/b/c + / = / // // Non-.-prefixed paths are still treated as relative paths, resolved like `./` // // Here are some examples: // // /a/b/c + d = /a/b/c/d // /a/b/c + d/ = /a/b/c/d // /a/b/c + d/e = /a/b/c/d/e interface ResolvePathOptions { basepath: string base: string to: string trailingSlash?: 'always' | 'never' | 'preserve' caseSensitive?: boolean parseCache?: ParsePathnameCache } function segmentToString(segment: Segment): string { const { type, value } = segment if (type === SEGMENT_TYPE_PATHNAME) { return value } const { prefixSegment, suffixSegment } = segment if (type === SEGMENT_TYPE_PARAM) { const param = value.substring(1) if (prefixSegment && suffixSegment) { return `${prefixSegment}{$${param}}${suffixSegment}` } else if (prefixSegment) { return `${prefixSegment}{$${param}}` } else if (suffixSegment) { return `{$${param}}${suffixSegment}` } } if (type === SEGMENT_TYPE_OPTIONAL_PARAM) { const param = value.substring(1) if (prefixSegment && suffixSegment) { return `${prefixSegment}{-$${param}}${suffixSegment}` } else if (prefixSegment) { return `${prefixSegment}{-$${param}}` } else if (suffixSegment) { return `{-$${param}}${suffixSegment}` } return `{-$${param}}` } if (type === SEGMENT_TYPE_WILDCARD) { if (prefixSegment && suffixSegment) { return `${prefixSegment}{$}${suffixSegment}` } else if (prefixSegment) { return `${prefixSegment}{$}` } else if (suffixSegment) { return `{$}${suffixSegment}` } } // This case should never happen, should we throw instead? return value } export function resolvePath({ basepath, base, to, trailingSlash = 'never', caseSensitive, parseCache, }: ResolvePathOptions) { base = removeBasepath(basepath, base, caseSensitive) to = removeBasepath(basepath, to, caseSensitive) let baseSegments = parsePathname(base, parseCache).slice() const toSegments = parsePathname(to, parseCache) if (baseSegments.length > 1 && last(baseSegments)?.value === '/') { baseSegments.pop() } for (let index = 0, length = toSegments.length; index < length; index++) { const toSegment = toSegments[index]! const value = toSegment.value if (value === '/') { if (!index) { // Leading slash baseSegments = [toSegment] } else if (index === length - 1) { // Trailing Slash baseSegments.push(toSegment) } else { // ignore inter-slashes } } else if (value === '..') { baseSegments.pop() } else if (value === '.') { // ignore } else { baseSegments.push(toSegment) } } if (baseSegments.length > 1) { if (last(baseSegments)!.value === '/') { if (trailingSlash === 'never') { baseSegments.pop() } } else if (trailingSlash === 'always') { baseSegments.push({ type: SEGMENT_TYPE_PATHNAME, value: '/' }) } } const segmentValues = baseSegments.map(segmentToString) const joined = joinPaths([basepath, ...segmentValues]) return joined } export type ParsePathnameCache = LRUCache<string, ReadonlyArray<Segment>> export const parsePathname = ( pathname?: string, cache?: ParsePathnameCache, ): ReadonlyArray<Segment> => { if (!pathname) return [] const cached = cache?.get(pathname) if (cached) return cached const parsed = baseParsePathname(pathname) cache?.set(pathname, parsed) return parsed } const PARAM_RE = /^\$.{1,}$/ // $paramName const PARAM_W_CURLY_BRACES_RE = /^(.*?)\{(\$[a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{$paramName}suffix const OPTIONAL_PARAM_W_CURLY_BRACES_RE = /^(.*?)\{-(\$[a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{-$paramName}suffix const WILDCARD_RE = /^\$$/ // $ const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix /** * Required: `/foo/$bar` ✅ * Prefix and Suffix: `/foo/prefix${bar}suffix` ✅ * Wildcard: `/foo/$` ✅ * Wildcard with Prefix and Suffix: `/foo/prefix{$}suffix` ✅ * * Optional param: `/foo/{-$bar}` * Optional param with Prefix and Suffix: `/foo/prefix{-$bar}suffix` * Future: * Optional named segment: `/foo/{bar}` * Optional named segment with Prefix and Suffix: `/foo/prefix{-bar}suffix` * Escape special characters: * - `/foo/[$]` - Static route * - `/foo/[$]{$foo} - Dynamic route with a static prefix of `$` * - `/foo/{$foo}[$]` - Dynamic route with a static suffix of `$` */ function baseParsePathname(pathname: string): ReadonlyArray<Segment> { pathname = cleanPath(pathname) const segments: Array<Segment> = [] if (pathname.slice(0, 1) === '/') { pathname = pathname.substring(1) segments.push({ type: SEGMENT_TYPE_PATHNAME, value: '/', }) } if (!pathname) { return segments } // Remove empty segments and '.' segments const split = pathname.split('/').filter(Boolean) segments.push( ...split.map((part): Segment => { // Check for wildcard with curly braces: prefix{$}suffix const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE) if (wildcardBracesMatch) { const prefix = wildcardBracesMatch[1] const suffix = wildcardBracesMatch[2] return { type: SEGMENT_TYPE_WILDCARD, value: '$', prefixSegment: prefix || undefined, suffixSegment: suffix || undefined, } } // Check for optional parameter format: prefix{-$paramName}suffix const optionalParamBracesMatch = part.match( OPTIONAL_PARAM_W_CURLY_BRACES_RE, ) if (optionalParamBracesMatch) { const prefix = optionalParamBracesMatch[1] const paramName = optionalParamBracesMatch[2]! const suffix = optionalParamBracesMatch[3] return { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: paramName, // Now just $paramName (no prefix) prefixSegment: prefix || undefined, suffixSegment: suffix || undefined, } } // Check for the new parameter format: prefix{$paramName}suffix const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE) if (paramBracesMatch) { const prefix = paramBracesMatch[1] const paramName = paramBracesMatch[2] const suffix = paramBracesMatch[3] return { type: SEGMENT_TYPE_PARAM, value: '' + paramName, prefixSegment: prefix || undefined, suffixSegment: suffix || undefined, } } // Check for bare parameter format: $paramName (without curly braces) if (PARAM_RE.test(part)) { const paramName = part.substring(1) return { type: SEGMENT_TYPE_PARAM, value: '$' + paramName, prefixSegment: undefined, suffixSegment: undefined, } } // Check for bare wildcard: $ (without curly braces) if (WILDCARD_RE.test(part)) { return { type: SEGMENT_TYPE_WILDCARD, value: '$', prefixSegment: undefined, suffixSegment: undefined, } } // Handle regular pathname segment return { type: SEGMENT_TYPE_PATHNAME, value: part.includes('%25') ? part .split('%25') .map((segment) => decodeURI(segment)) .join('%25') : decodeURI(part), } }), ) if (pathname.slice(-1) === '/') { pathname = pathname.substring(1) segments.push({ type: SEGMENT_TYPE_PATHNAME, value: '/', }) } return segments } interface InterpolatePathOptions { path?: string params: Record<string, unknown> leaveWildcards?: boolean leaveParams?: boolean // Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params decodeCharMap?: Map<string, string> parseCache?: ParsePathnameCache } type InterPolatePathResult = { interpolatedPath: string usedParams: Record<string, unknown> isMissingParams: boolean // true if any params were not available when being looked up in the params object } export function interpolatePath({ path, params, leaveWildcards, leaveParams, decodeCharMap, parseCache, }: InterpolatePathOptions): InterPolatePathResult { const interpolatedPathSegments = parsePathname(path, parseCache) function encodeParam(key: string): any { const value = params[key] const isValueString = typeof value === 'string' if (key === '*' || key === '_splat') { // the splat/catch-all routes shouldn't have the '/' encoded out return isValueString ? encodeURI(value) : value } else { return isValueString ? encodePathParam(value, decodeCharMap) : value } } // Tracking if any params are missing in the `params` object // when interpolating the path let isMissingParams = false const usedParams: Record<string, unknown> = {} const interpolatedPath = joinPaths( interpolatedPathSegments.map((segment) => { if (segment.type === SEGMENT_TYPE_PATHNAME) { return segment.value } if (segment.type === SEGMENT_TYPE_WILDCARD) { usedParams._splat = params._splat // TODO: Deprecate * usedParams['*'] = params._splat const segmentPrefix = segment.prefixSegment || '' const segmentSuffix = segment.suffixSegment || '' // Check if _splat parameter is missing if (!('_splat' in params)) { isMissingParams = true // For missing splat parameters, just return the prefix and suffix without the wildcard if (leaveWildcards) { return `${segmentPrefix}${segment.value}${segmentSuffix}` } // If there is a prefix or suffix, return them joined, otherwise omit the segment if (segmentPrefix || segmentSuffix) { return `${segmentPrefix}${segmentSuffix}` } return undefined } const value = encodeParam('_splat') if (leaveWildcards) { return `${segmentPrefix}${segment.value}${value ?? ''}${segmentSuffix}` } return `${segmentPrefix}${value}${segmentSuffix}` } if (segment.type === SEGMENT_TYPE_PARAM) { const key = segment.value.substring(1) if (!isMissingParams && !(key in params)) { isMissingParams = true } usedParams[key] = params[key] const segmentPrefix = segment.prefixSegment || '' const segmentSuffix = segment.suffixSegment || '' if (leaveParams) { const value = encodeParam(segment.value) return `${segmentPrefix}${segment.value}${value ?? ''}${segmentSuffix}` } return `${segmentPrefix}${encodeParam(key) ?? 'undefined'}${segmentSuffix}` } if (segment.type === SEGMENT_TYPE_OPTIONAL_PARAM) { const key = segment.value.substring(1) const segmentPrefix = segment.prefixSegment || '' const segmentSuffix = segment.suffixSegment || '' // Check if optional parameter is missing or undefined if (!(key in params) || params[key] == null) { if (leaveWildcards) { return `${segmentPrefix}${key}${segmentSuffix}` } // For optional params with prefix/suffix, keep the prefix/suffix but omit the param if (segmentPrefix || segmentSuffix) { return `${segmentPrefix}${segmentSuffix}` } // If no prefix/suffix, omit the entire segment return undefined } usedParams[key] = params[key] if (leaveParams) { const value = encodeParam(segment.value) return `${segmentPrefix}${segment.value}${value ?? ''}${segmentSuffix}` } if (leaveWildcards) { return `${segmentPrefix}${key}${encodeParam(key) ?? ''}${segmentSuffix}` } return `${segmentPrefix}${encodeParam(key) ?? ''}${segmentSuffix}` } return segment.value }), ) return { usedParams, interpolatedPath, isMissingParams } } function encodePathParam(value: string, decodeCharMap?: Map<string, string>) { let encoded = encodeURIComponent(value) if (decodeCharMap) { for (const [encodedChar, char] of decodeCharMap) { encoded = encoded.replaceAll(encodedChar, char) } } return encoded } export function matchPathname( basepath: string, currentPathname: string, matchLocation: Pick<MatchLocation, 'to' | 'fuzzy' | 'caseSensitive'>, parseCache?: ParsePathnameCache, ): AnyPathParams | undefined { const pathParams = matchByPath( basepath, currentPathname, matchLocation, parseCache, ) // const searchMatched = matchBySearch(location.search, matchLocation) if (matchLocation.to && !pathParams) { return } return pathParams ?? {} } export function removeBasepath( basepath: string, pathname: string, caseSensitive: boolean = false, ) { // normalize basepath and pathname for case-insensitive comparison if needed const normalizedBasepath = caseSensitive ? basepath : basepath.toLowerCase() const normalizedPathname = caseSensitive ? pathname : pathname.toLowerCase() switch (true) { // default behaviour is to serve app from the root - pathname // left untouched case normalizedBasepath === '/': return pathname // shortcut for removing the basepath if it matches the pathname case normalizedPathname === normalizedBasepath: return '' // in case pathname is shorter than basepath - there is // nothing to remove case pathname.length < basepath.length: return pathname // avoid matching partial segments - strict equality handled // earlier, otherwise, basepath separated from pathname with // separator, therefore lack of separator means partial // segment match (`/app` should not match `/application`) case normalizedPathname[normalizedBasepath.length] !== '/': return pathname // remove the basepath from the pathname if it starts with it case normalizedPathname.startsWith(normalizedBasepath): return pathname.slice(basepath.length) // otherwise, return the pathname as is default: return pathname } } export function matchByPath( basepath: string, from: string, { to, fuzzy, caseSensitive, }: Pick<MatchLocation, 'to' | 'caseSensitive' | 'fuzzy'>, parseCache?: ParsePathnameCache, ): Record<string, string> | undefined { // check basepath first if (basepath !== '/' && !from.startsWith(basepath)) { return undefined } // Remove the base path from the pathname from = removeBasepath(basepath, from, caseSensitive) // Default to to $ (wildcard) to = removeBasepath(basepath, `${to ?? '$'}`, caseSensitive) // Parse the from and to const baseSegments = parsePathname( from.startsWith('/') ? from : `/${from}`, parseCache, ) const routeSegments = parsePathname( to.startsWith('/') ? to : `/${to}`, parseCache, ) const params: Record<string, string> = {} const result = isMatch( baseSegments, routeSegments, params, fuzzy, caseSensitive, ) return result ? params : undefined } function isMatch( baseSegments: ReadonlyArray<Segment>, routeSegments: ReadonlyArray<Segment>, params: Record<string, string>, fuzzy?: boolean, caseSensitive?: boolean, ): boolean { let baseIndex = 0 let routeIndex = 0 while (baseIndex < baseSegments.length || routeIndex < routeSegments.length) { const baseSegment = baseSegments[baseIndex] const routeSegment = routeSegments[routeIndex] if (routeSegment) { if (routeSegment.type === SEGMENT_TYPE_WILDCARD) { // Capture all remaining segments for a wildcard const remainingBaseSegments = baseSegments.slice(baseIndex) let _splat: string // If this is a wildcard with prefix/suffix, we need to handle the first segment specially if (routeSegment.prefixSegment || routeSegment.suffixSegment) { if (!baseSegment) return false const prefix = routeSegment.prefixSegment || '' const suffix = routeSegment.suffixSegment || '' // Check if the base segment starts with prefix and ends with suffix const baseValue = baseSegment.value if ('prefixSegment' in routeSegment) { if (!baseValue.startsWith(prefix)) { return false } } if ('suffixSegment' in routeSegment) { if ( !baseSegments[baseSegments.length - 1]?.value.endsWith(suffix) ) { return false } } let rejoinedSplat = decodeURI( joinPaths(remainingBaseSegments.map((d) => d.value)), ) // Remove the prefix and suffix from the rejoined splat if (prefix && rejoinedSplat.startsWith(prefix)) { rejoinedSplat = rejoinedSplat.slice(prefix.length) } if (suffix && rejoinedSplat.endsWith(suffix)) { rejoinedSplat = rejoinedSplat.slice( 0, rejoinedSplat.length - suffix.length, ) } _splat = rejoinedSplat } else { // If no prefix/suffix, just rejoin the remaining segments _splat = decodeURI( joinPaths(remainingBaseSegments.map((d) => d.value)), ) } // TODO: Deprecate * params['*'] = _splat params['_splat'] = _splat return true } if (routeSegment.type === SEGMENT_TYPE_PATHNAME) { if (routeSegment.value === '/' && !baseSegment?.value) { routeIndex++ continue } if (baseSegment) { if (caseSensitive) { if (routeSegment.value !== baseSegment.value) { return false } } else if ( routeSegment.value.toLowerCase() !== baseSegment.value.toLowerCase() ) { return false } baseIndex++ routeIndex++ continue } else { return false } } if (routeSegment.type === SEGMENT_TYPE_PARAM) { if (!baseSegment) { return false } if (baseSegment.value === '/') { return false } let _paramValue = '' let matched = false // If this param has prefix/suffix, we need to extract the actual parameter value if (routeSegment.prefixSegment || routeSegment.suffixSegment) { const prefix = routeSegment.prefixSegment || '' const suffix = routeSegment.suffixSegment || '' // Check if the base segment starts with prefix and ends with suffix const baseValue = baseSegment.value if (prefix && !baseValue.startsWith(prefix)) { return false } if (suffix && !baseValue.endsWith(suffix)) { return false } let paramValue = baseValue if (prefix && paramValue.startsWith(prefix)) { paramValue = paramValue.slice(prefix.length) } if (suffix && paramValue.endsWith(suffix)) { paramValue = paramValue.slice(0, paramValue.length - suffix.length) } _paramValue = decodeURIComponent(paramValue) matched = true } else { // If no prefix/suffix, just decode the base segment value _paramValue = decodeURIComponent(baseSegment.value) matched = true } if (matched) { params[routeSegment.value.substring(1)] = _paramValue baseIndex++ } routeIndex++ continue } if (routeSegment.type === SEGMENT_TYPE_OPTIONAL_PARAM) { // Optional parameters can be missing - don't fail the match if (!baseSegment) { // No base segment for optional param - skip this route segment routeIndex++ continue } if (baseSegment.value === '/') { // Skip slash segments for optional params routeIndex++ continue } let _paramValue = '' let matched = false // If this optional param has prefix/suffix, we need to extract the actual parameter value if (routeSegment.prefixSegment || routeSegment.suffixSegment) { const prefix = routeSegment.prefixSegment || '' const suffix = routeSegment.suffixSegment || '' // Check if the base segment starts with prefix and ends with suffix const baseValue = baseSegment.value if ( (!prefix || baseValue.startsWith(prefix)) && (!suffix || baseValue.endsWith(suffix)) ) { let paramValue = baseValue if (prefix && paramValue.startsWith(prefix)) { paramValue = paramValue.slice(prefix.length) } if (suffix && paramValue.endsWith(suffix)) { paramValue = paramValue.slice( 0, paramValue.length - suffix.length, ) } _paramValue = decodeURIComponent(paramValue) matched = true } } else { // For optional params without prefix/suffix, we need to check if the current // base segment should match this optional param or a later route segment // Look ahead to see if there's a later route segment that matches the current base segment let shouldMatchOptional = true for ( let lookAhead = routeIndex + 1; lookAhead < routeSegments.length; lookAhead++ ) { const futureRouteSegment = routeSegments[lookAhead] if ( futureRouteSegment?.type === SEGMENT_TYPE_PATHNAME && futureRouteSegment.value === baseSegment.value ) { // The current base segment matches a future pathname segment, // so we should skip this optional parameter shouldMatchOptional = false break } // If we encounter a required param or wildcard, stop looking ahead if ( futureRouteSegment?.type === SEGMENT_TYPE_PARAM || futureRouteSegment?.type === SEGMENT_TYPE_WILDCARD ) { if (baseSegments.length < routeSegments.length) { shouldMatchOptional = false } break } } if (shouldMatchOptional) { // If no prefix/suffix, just decode the base segment value _paramValue = decodeURIComponent(baseSegment.value) matched = true } } if (matched) { params[routeSegment.value.substring(1)] = _paramValue baseIndex++ } routeIndex++ continue } } // If we have base segments left but no route segments, it's a fuzzy match if (baseIndex < baseSegments.length && routeIndex >= routeSegments.length) { params['**'] = joinPaths( baseSegments.slice(baseIndex).map((d) => d.value), ) return !!fuzzy && routeSegments[routeSegments.length - 1]?.value !== '/' } // If we have route segments left but no base segments, check if remaining are optional if (routeIndex < routeSegments.length && baseIndex >= baseSegments.length) { // Check if all remaining route segments are optional for (let i = routeIndex; i < routeSegments.length; i++) { if (routeSegments[i]?.type !== SEGMENT_TYPE_OPTIONAL_PARAM) { return false } } // All remaining are optional, so we can finish break } break } return true }