Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support square brackets for advanced dynamic path segments (prefix, suffix & multiple) #2734

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
4 changes: 3 additions & 1 deletion packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export type ParsePathParams<
? TAcc
: TPossiblyParam | TAcc
: TAcc
: TAcc
: T extends `${string}[${infer TBrackets}]${infer TRest}`
? ParsePathParams<TRest, TBrackets extends '' ? TAcc : TBrackets | TAcc>
: TAcc

export type Join<T, TDelimiter extends string = '/'> = T extends []
? ''
Expand Down
99 changes: 95 additions & 4 deletions packages/react-router/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,10 @@ export function parsePathname(pathname?: string): Array<Segment> {
}
}

if (part.charAt(0) === '$') {
if (
part.charAt(0) === '$' ||
(part.includes('[') && part.includes(']'))
) {
return {
type: 'param',
value: part,
Expand All @@ -199,6 +202,63 @@ export function parsePathname(pathname?: string): Array<Segment> {
return segments
}

function parseSquareBrackets(
template: string,
input: string,
decodeCharMap?: Map<string, string>,
): {
params: Array<{ key: string; value: string }> | null
isExactMatch: boolean
} {
// Regular expression to find placeholders in the format [key]
const regex = /\[([^\]]+)\]/g
const keys: Array<string> = []
let lastIndex = 0
let match: RegExpExecArray | null

// Extract keys and static parts from the template
let pattern = '^' // Start of string
while ((match = regex.exec(template)) !== null) {
if (match[1]) {
keys.push(match[1])
}
// Add text between placeholders as fixed text in the pattern
pattern +=
escapeRegExp(template.substring(lastIndex, match.index)) + '([^\\-]*)'
lastIndex = regex.lastIndex
}
pattern += escapeRegExp(template.substring(lastIndex)) + '$' // End of string

// Match the input against the dynamic regex pattern
const patternRegex = new RegExp(pattern)
const inputMatch = patternRegex.exec(input)

// Ensure that input matches the template in its entirety
const isExactMatch = !!inputMatch

// The first element in the match is the full match, so start at index 1
if (!inputMatch || inputMatch.length - 1 !== keys.length) {
return { params: null, isExactMatch: false }
}

// Map the extracted parts to their respective keys
const params: Array<{ key: string; value: string }> = []
keys.forEach((key, index) => {
const value = inputMatch[index + 1] || ''
params.push({
key,
value: encodePathParam(value, decodeCharMap),
})
})

return { params, isExactMatch }
}

// Helper function to escape special characters for use in regex
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

interface InterpolatePathOptions {
path?: string
params: Record<string, unknown>
Expand All @@ -224,6 +284,12 @@ export function interpolatePath({
if (['*', '_splat'].includes(key)) {
// the splat/catch-all routes shouldn't have the '/' encoded out
encodedParams[key] = isValueString ? encodeURI(value) : value
} else if (isValueString && key.includes('[') && key.includes(']')) {
parseSquareBrackets(key, value, decodeCharMap).params?.forEach(
({ key, value }) => {
encodedParams[key] = encodePathParam(value, decodeCharMap)
},
)
} else {
encodedParams[key] = isValueString
? encodePathParam(value, decodeCharMap)
Expand Down Expand Up @@ -406,10 +472,35 @@ export function matchByPath(
if (baseSegment.value === '/') {
return false
}

if (baseSegment.value.charAt(0) !== '$') {
params[routeSegment.value.substring(1)] = decodeURIComponent(
baseSegment.value,
)
// params[routeSegment.value.substring(1).replaceAll(/(\[|\])/g, '')] =
const routeSegmentValue = routeSegment.value
if (
routeSegmentValue.includes('[') &&
routeSegmentValue.includes(']')
) {
if (
!routeSegment.value.startsWith('[') &&
routeSegment.value.charAt(0) !== baseSegment.value.charAt(0)
) {
return false
}
const parsed = parseSquareBrackets(
routeSegmentValue,
baseSegment.value,
)
// if (!parsed.isExactMatch && !matchLocation.fuzzy) {
if (!parsed.isExactMatch) {
return false
}
parsed.params?.forEach(({ key, value }) => {
params[key] = decodeURIComponent(value)
})
} else {
const key = routeSegmentValue.substring(1)
params[key] = decodeURIComponent(baseSegment.value)
}
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/react-router/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,14 @@ export interface SplatParams {

export type ResolveParams<TPath extends string> =
ParseSplatParams<TPath> extends never
? Record<ParsePathParams<TPath>, string>
: Record<ParsePathParams<TPath>, string> & SplatParams
? Record<Extract<ParsePathParams<TPath>, string>, string>
: Record<Extract<ParsePathParams<TPath>, string>, string> & SplatParams

export type ParseParamsFn<TPath extends string, TParams> = (
rawParams: ResolveParams<TPath>,
) => TParams extends Record<ParsePathParams<TPath>, any>
? TParams
: Record<ParsePathParams<TPath>, any>
: Record<Extract<ParsePathParams<TPath>, string>, any>

export type StringifyParamsFn<TPath extends string, TParams> = (
params: TParams,
Expand Down
15 changes: 15 additions & 0 deletions packages/react-router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,21 @@ export class Router<
.sort((a, b) => {
const minLength = Math.min(a.scores.length, b.scores.length)

// Sort segments with parameters that don't start with `$`
for (let i = 0; i < minLength; i++) {
const paramA = a.parsed[i]!.value
const paramB = b.parsed[i]!.value

if (!paramA.startsWith('$') && !paramB.startsWith('$')) {
const countA = (paramA.match(/\[/g) || []).length
const countB = (paramB.match(/\[/g) || []).length

if (countA !== countB) {
return countA - countB // Sort descending by the number of `[`
}
}
}

// Sort by min available score
for (let i = 0; i < minLength; i++) {
if (a.scores[i] !== b.scores[i]) {
Expand Down
Loading