UNPKG

@tanstack/router-core

Version:

Modern and scalable routing for React applications

311 lines (284 loc) 8.84 kB
import { last } from './utils' import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_WILDCARD, } from './new-process-route-tree' import type { SieveCache } from './sieve-cache' import type { DynamicPathSegment } from './new-process-route-tree' import type { AnyRoute } from './route' /** Join path segments, cleaning duplicate slashes between parts. */ export function joinPaths(paths: Array<string | undefined>) { return cleanPath( paths .filter((val) => { return val !== undefined }) .join('/'), ) } /** Remove repeated slashes from a path string. */ export function cleanPath(path: string) { // remove double slashes return path.replace(/\/{2,}/g, '/') } /** Trim leading slashes (except preserving root '/'). */ export function trimPathLeft(path: string) { return path === '/' ? path : path.replace(/^\/+/, '') } /** Trim trailing slashes (except preserving root '/'). */ export function trimPathRight(path: string) { const len = path.length return len > 1 && path[len - 1] === '/' ? path.replace(/\/+$/, '') : path } /** Trim both leading and trailing slashes. */ export function trimPath(path: string) { return trimPathRight(trimPathLeft(path)) } /** Remove a trailing slash from value when appropriate for comparisons. */ 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 /** * Compare two pathnames for exact equality after normalizing trailing slashes * relative to the provided `basepath`. */ 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 { base: string to: string trailingSlash?: 'always' | 'never' | 'preserve' cache?: SieveCache<string, string> } /** * Resolve a destination path against a base, honoring trailing-slash policy * and supporting relative segments (`.`/`..`) and absolute `to` values. */ export function resolvePath({ base, to, trailingSlash = 'never', cache, }: ResolvePathOptions) { if (to.includes('//')) { to = cleanPath(to) } if (to.startsWith('/')) { if (to.length === 1 || trailingSlash === 'preserve') { return to } if (trailingSlash === 'always') { return to.endsWith('/') ? to : `${to}/` } return to.endsWith('/') ? to.slice(0, -1) : to } const isBase = to === '.' let key if (cache) { // `trailingSlash` is static per router, so it doesn't need to be part of the cache key key = isBase ? base : base + '\0' + to const cached = cache.get(key) if (cached) return cached } let baseSegments: Array<string> if (isBase) { baseSegments = base.split('/') } else { if (base.includes('//')) { base = cleanPath(base) } baseSegments = base.split('/') while (baseSegments.length > 1 && last(baseSegments) === '') { baseSegments.pop() } const toSegments = to.split('/') for (let index = 0, length = toSegments.length; index < length; index++) { const value = toSegments[index]! if (value === '') { if (!index) { // Leading slash baseSegments = [value] } else if (index === length - 1) { // Trailing Slash baseSegments.push(value) } else { // ignore inter-slashes } } else if (value === '..') { if (baseSegments.length > 1) { baseSegments.pop() } else { baseSegments = [''] } } else if (value === '.') { // ignore } else { baseSegments.push(value) } } } if (baseSegments.length > 1) { if (last(baseSegments) === '') { if (trailingSlash === 'never') { baseSegments.pop() } } else if (trailingSlash === 'always') { baseSegments.push('') } } const joined = baseSegments.join('/') const result = (isBase ? cleanPath(joined) : joined) || '/' if (key && cache) cache.set(key, result) return result } /** * Create a pre-compiled decode config from allowed characters. * Created once for the router's fixed encoding configuration. */ export function compileDecodeCharMap( pathParamsAllowedCharacters: ReadonlyArray<string>, ) { const charMap = new Map( pathParamsAllowedCharacters.map((char) => [encodeURIComponent(char), char]), ) // Encoded keys contain no '|', and only these four regexp metacharacters. const regex = new RegExp( [...charMap.keys()].join('|').replace(/[.*()]/g, '\\$&'), 'g', ) return (encoded: string) => encoded.replace(regex, (match) => charMap.get(match) ?? match) } export type InterpolationSegment = string | DynamicPathSegment export type RouteInterpolation = Array<InterpolationSegment> & { names?: Array<string> } export function getRouteSegments(route: AnyRoute) { return route._interpolation } /** A splat is missing when it has no value; `0` and `false` are stringified like any other param. */ function isMissingSplat(value: unknown): boolean { return value == null || value === '' } /** Devtools checks navigation availability separately from the hot formatter. */ export function hasMissingPathParams( segments: RouteInterpolation, params: Record<string, unknown>, ): boolean { return segments.some((part) => { if (typeof part === 'string') { return false } const [kind, key] = part return kind === SEGMENT_TYPE_WILDCARD ? isMissingSplat(params[key]) : kind === SEGMENT_TYPE_PARAM && !(key in params) }) } function encodeParam( key: string, value: unknown, decoder: ((encoded: string) => string) | undefined, ): string { if (typeof value !== 'string') { return '' + (value ?? undefined) } const splat = key === '_splat' // Early return if the splat contains only URL-safe characters. if (splat && (!value || /^[a-zA-Z0-9\-._~!/]*$/.test(value))) { return value } let encoded = encodeURIComponent(value) if (splat) { // Splats preserve '/', but still encode spaces, '+', '?' and '#'. // Restore separators before allowed characters can decode a literal '%2F'. encoded = encoded.replaceAll('%2F', '/') } return decoder ? decoder(encoded) : encoded } /** Substitute current values into parsed segments, optionally collecting raw params. */ export function interpolatePath( path: string, segments: RouteInterpolation, params: Record<string, unknown>, decoder?: (encoded: string) => string, usedParams?: Record<string, unknown>, ): string { // One parsed template serves both trailing-slash variants. const trailingSlash = path.endsWith('/') ? '/' : '' let joined = '' for (const part of segments) { if (typeof part === 'string') { joined += part continue } const [kind, key, prefix, rawSuffix] = part const splat = kind === SEGMENT_TYPE_WILDCARD const suffix = splat && rawSuffix !== undefined ? rawSuffix + trailingSlash : rawSuffix let paramValue = params[key] // An omitted optional contributes neither a segment nor used-param metadata. if (kind === SEGMENT_TYPE_OPTIONAL_PARAM && paramValue == null) { continue } if (usedParams) { // Match identity needs current raw values, never data retained from another call. usedParams[key] = paramValue // TODO: Deprecate * if (splat) { usedParams['*'] = paramValue } } if (splat && isMissingSplat(paramValue)) { // A missing wildcard keeps its affixes, but omits a bare segment. if (prefix === '/' && !suffix) { continue } paramValue = '' } joined += prefix + encodeParam(key, paramValue, decoder) + (suffix || '') } return joined + trailingSlash || '/' }