UNPKG

@tanstack/router-core

Version:

Modern and scalable routing for React applications

437 lines (387 loc) 12.9 kB
import { isServer } from '@tanstack/router-core/isServer' import { last } from './utils' import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, parseSegment, } from './new-process-route-tree' import type { LRUCache } from './lru-cache' /** 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(/^\/{1,}/, '') } /** Trim trailing slashes (except preserving root '/'). */ export function trimPathRight(path: string) { const len = path.length return len > 1 && path[len - 1] === '/' ? path.replace(/\/{1,}$/, '') : 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?: LRUCache<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) { const isAbsolute = to.startsWith('/') const isBase = !isAbsolute && to === '.' let key if (cache) { // `trailingSlash` is static per router, so it doesn't need to be part of the cache key key = isAbsolute ? to : 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 (isAbsolute) { baseSegments = to.split('/') } else { 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 === '..') { baseSegments.pop() } 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('') } } let segment let joined = '' for (let i = 0; i < baseSegments.length; i++) { if (i > 0) joined += '/' const part = baseSegments[i]! if (!part) continue segment = parseSegment(part, 0, segment) const kind = segment[0] if (kind === SEGMENT_TYPE_PATHNAME) { joined += part continue } const end = segment[5] const prefix = part.substring(0, segment[1]) const suffix = part.substring(segment[4], end) const value = part.substring(segment[2], segment[3]) if (kind === SEGMENT_TYPE_PARAM) { joined += prefix || suffix ? `${prefix}{$${value}}${suffix}` : `$${value}` } else if (kind === SEGMENT_TYPE_WILDCARD) { joined += prefix || suffix ? `${prefix}{$}${suffix}` : '$' } else { // SEGMENT_TYPE_OPTIONAL_PARAM joined += `${prefix}{-$${value}}${suffix}` } } joined = cleanPath(joined) const result = joined || '/' if (key && cache) cache.set(key, result) return result } /** * Create a pre-compiled decode config from allowed characters. * This should be called once at router initialization. */ export function compileDecodeCharMap( pathParamsAllowedCharacters: ReadonlyArray<string>, ) { const charMap = new Map( pathParamsAllowedCharacters.map((char) => [encodeURIComponent(char), char]), ) // Escape special regex characters and join with | const pattern = Array.from(charMap.keys()) .map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) .join('|') const regex = new RegExp(pattern, 'g') return (encoded: string) => encoded.replace(regex, (match) => charMap.get(match) ?? match) } interface InterpolatePathOptions { path?: string params: Record<string, unknown> /** * A function that decodes a path parameter value. * Obtained from `compileDecodeCharMap(pathParamsAllowedCharacters)`. */ decoder?: (encoded: string) => string /** * @internal * For testing only, in development mode we use the router.isServer value */ server?: boolean } 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 } function encodeParam( key: string, params: InterpolatePathOptions['params'], decoder: InterpolatePathOptions['decoder'], ): any { const value = params[key] if (typeof value !== 'string') return value if (key === '_splat') { // Early return if value only contains URL-safe characters (performance optimization) if (/^[a-zA-Z0-9\-._~!/]*$/.test(value)) return value // the splat/catch-all routes shouldn't have the '/' encoded out // Use encodeURIComponent for each segment to properly encode spaces, // plus signs, and other special characters that encodeURI leaves unencoded return value .split('/') .map((segment) => encodePathParam(segment, decoder)) .join('/') } else { return encodePathParam(value, decoder) } } /** * Interpolate params and wildcards into a route path template. * * - Encodes params safely (configurable allowed characters) * - Supports `{-$optional}` segments, `{prefix{$id}suffix}` and `{$}` wildcards */ export function interpolatePath({ path, params, decoder, // `server` is marked @internal and stripped from .d.ts by `stripInternal`. // We avoid destructuring it in the function signature so the emitted // declaration doesn't reference a property that no longer exists. ...rest }: InterpolatePathOptions): InterPolatePathResult { // Tracking if any params are missing in the `params` object // when interpolating the path let isMissingParams = false const usedParams: Record<string, unknown> = Object.create(null) if (!path || path === '/') return { interpolatedPath: '/', usedParams, isMissingParams } if (!path.includes('$')) return { interpolatedPath: path, usedParams, isMissingParams } if (isServer ?? rest.server) { // Fast path for common templates like `/posts/$id` or `/files/$`. // Braced segments (`{...}`) are more complex (prefix/suffix/optional) and are // handled by the general parser below. if (path.indexOf('{') === -1) { const length = path.length let cursor = 0 let joined = '' while (cursor < length) { // Skip slashes between segments. '/' code is 47 while (cursor < length && path.charCodeAt(cursor) === 47) cursor++ if (cursor >= length) break const start = cursor let end = path.indexOf('/', cursor) if (end === -1) end = length cursor = end const part = path.substring(start, end) if (!part) continue // `$id` or `$` (splat). '$' code is 36 if (part.charCodeAt(0) === 36) { if (part.length === 1) { const splat = params._splat usedParams._splat = splat // TODO: Deprecate * usedParams['*'] = splat if (!splat) { isMissingParams = true continue } const value = encodeParam('_splat', params, decoder) joined += '/' + value } else { const key = part.substring(1) if (!isMissingParams && !(key in params)) { isMissingParams = true } usedParams[key] = params[key] const value = encodeParam(key, params, decoder) ?? 'undefined' joined += '/' + value } } else { joined += '/' + part } } if (path.endsWith('/')) joined += '/' const interpolatedPath = joined || '/' return { usedParams, interpolatedPath, isMissingParams } } } const length = path.length let cursor = 0 let segment let joined = '' while (cursor < length) { const start = cursor segment = parseSegment(path, start, segment) const end = segment[5] cursor = end + 1 if (start === end) continue const kind = segment[0] if (kind === SEGMENT_TYPE_PATHNAME) { joined += '/' + path.substring(start, end) continue } if (kind === SEGMENT_TYPE_WILDCARD) { const splat = params._splat usedParams._splat = splat // TODO: Deprecate * usedParams['*'] = splat const prefix = path.substring(start, segment[1]) const suffix = path.substring(segment[4], end) // Check if _splat parameter is missing. _splat could be missing if undefined or an empty string or some other falsy value. if (!splat) { isMissingParams = true // For missing splat parameters, just return the prefix and suffix without the wildcard // If there is a prefix or suffix, return them joined, otherwise omit the segment if (prefix || suffix) { joined += '/' + prefix + suffix } continue } const value = encodeParam('_splat', params, decoder) joined += '/' + prefix + value + suffix continue } if (kind === SEGMENT_TYPE_PARAM) { const key = path.substring(segment[2], segment[3]) if (!isMissingParams && !(key in params)) { isMissingParams = true } usedParams[key] = params[key] const prefix = path.substring(start, segment[1]) const suffix = path.substring(segment[4], end) const value = encodeParam(key, params, decoder) ?? 'undefined' joined += '/' + prefix + value + suffix continue } if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) { const key = path.substring(segment[2], segment[3]) const valueRaw = params[key] // Check if optional parameter is missing or undefined if (valueRaw == null) continue usedParams[key] = valueRaw const prefix = path.substring(start, segment[1]) const suffix = path.substring(segment[4], end) const value = encodeParam(key, params, decoder) ?? '' joined += '/' + prefix + value + suffix continue } } if (path.endsWith('/')) joined += '/' const interpolatedPath = joined || '/' return { usedParams, interpolatedPath, isMissingParams } } function encodePathParam( value: string, decoder?: InterpolatePathOptions['decoder'], ) { const encoded = encodeURIComponent(value) return decoder?.(encoded) ?? encoded }