From c87ec3fbd3b6dc0bde37fef6e14384fd54110236 Mon Sep 17 00:00:00 2001 From: CanRau Date: Sat, 9 Nov 2024 16:18:18 -0500 Subject: [PATCH 1/5] feat(react-router): support prefixes, suffixes & multi params --- packages/react-router/src/path.ts | 84 +++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/packages/react-router/src/path.ts b/packages/react-router/src/path.ts index cf4b99b9c6..c6e70197a2 100644 --- a/packages/react-router/src/path.ts +++ b/packages/react-router/src/path.ts @@ -174,7 +174,10 @@ export function parsePathname(pathname?: string): Array { } } - if (part.charAt(0) === '$') { + if ( + part.charAt(0) === '$' || + (part.includes('[') && part.includes(']')) + ) { return { type: 'param', value: part, @@ -199,6 +202,60 @@ export function parsePathname(pathname?: string): Array { return segments } +function parseUsingTemplate( + template: string, + input: string, + decodeCharMap?: Map, +): Array<{ key: string; value: string }> | null { + // Regular expression to find placeholders in the format [key] + const regex = /\[([^\]]+)\]/g + const keys: Array = [] + let lastIndex = 0 + let match: RegExpExecArray | null + + // Extract keys and static parts from the template + let pattern = '^' + while ((match = regex.exec(template)) !== null) { + if (match[1]) { + keys.push(match[1]) + } + // Add text between placeholders as static text in pattern + pattern += + escapeRegExp(template.substring(lastIndex, match.index)) + '(.*?)' + lastIndex = regex.lastIndex + } + pattern += escapeRegExp(template.substring(lastIndex)) + '$' + + // Match the input against the dynamic regex pattern + const patternRegex = new RegExp(pattern) + const inputMatch = patternRegex.exec(input) + + // The first element in the match is the full match, so start at index 1 + if (!inputMatch || inputMatch.length - 1 !== keys.length) { + return null + } + + // Map the extracted parts to their respective keys + // const result: { [key: string]: string } = {} + // keys.forEach((key, index) => { + // result[key] = encodePathParam(inputMatch[index + 1] || '', decodeCharMap) + // }) + const result: Array<{ key: string; value: string }> = [] + keys.forEach((key, index) => { + result.push({ + key, + value: encodePathParam(inputMatch[index + 1] || '', decodeCharMap), + }) + }) + + return result +} + +// 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 @@ -224,6 +281,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(']')) { + parseUsingTemplate(key, value, decodeCharMap)?.forEach( + ({ key, value }) => { + encodedParams[key] = encodePathParam(value, decodeCharMap) + }, + ) } else { encodedParams[key] = isValueString ? encodePathParam(value, decodeCharMap) @@ -406,10 +469,23 @@ 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(']') + ) { + parseUsingTemplate(routeSegmentValue, baseSegment.value)?.forEach( + ({ key, value }) => { + params[key] = decodeURIComponent(value) + }, + ) + } else { + const key = routeSegmentValue.substring(1) + params[key] = decodeURIComponent(baseSegment.value) + } } } } From fe9f5bb755f854dcff4af6bd765cd5880a00b269 Mon Sep 17 00:00:00 2001 From: CanRau Date: Mon, 11 Nov 2024 10:28:04 -0500 Subject: [PATCH 2/5] type(react-router): support square bracket params --- packages/react-router/src/link.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 63c421b6f1..e6cba2599b 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -83,7 +83,9 @@ export type ParsePathParams< ? TAcc : TPossiblyParam | TAcc : TAcc - : TAcc + : T extends `${string}[${infer TBrackets}]${infer TRest}` + ? ParsePathParams + : TAcc export type Join = T extends [] ? '' From b107ce8eab84b611f49f9fd3a9f610eec1f16bb2 Mon Sep 17 00:00:00 2001 From: CanRau Date: Mon, 11 Nov 2024 10:38:06 -0500 Subject: [PATCH 3/5] type(react-router): update ResolveParams & ParseParamsFn --- packages/react-router/src/route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts index ed36934ba2..32e8fbd905 100644 --- a/packages/react-router/src/route.ts +++ b/packages/react-router/src/route.ts @@ -111,14 +111,14 @@ export interface SplatParams { export type ResolveParams = ParseSplatParams extends never - ? Record, string> - : Record, string> & SplatParams + ? Record, string>, string> // Ensures extraction as strings + : Record, string>, string> & SplatParams export type ParseParamsFn = ( rawParams: ResolveParams, ) => TParams extends Record, any> ? TParams - : Record, any> + : Record, string>, any> // Ensures the parameters are handled as desired export type StringifyParamsFn = ( params: TParams, From 593c1a7dfee7cdc04f4dce384758951b088a3b51 Mon Sep 17 00:00:00 2001 From: CanRau Date: Mon, 11 Nov 2024 12:30:53 -0500 Subject: [PATCH 4/5] fix(react-router): isExactMatch --- packages/react-router/src/path.ts | 54 +++++++++++++++++++----------- packages/react-router/src/route.ts | 4 +-- 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/packages/react-router/src/path.ts b/packages/react-router/src/path.ts index c6e70197a2..39fbf82417 100644 --- a/packages/react-router/src/path.ts +++ b/packages/react-router/src/path.ts @@ -202,11 +202,14 @@ export function parsePathname(pathname?: string): Array { return segments } -function parseUsingTemplate( +function parseSquareBrackets( template: string, input: string, decodeCharMap?: Map, -): Array<{ key: string; value: string }> | null { +): { + params: Array<{ key: string; value: string }> | null + isExactMatch: boolean +} { // Regular expression to find placeholders in the format [key] const regex = /\[([^\]]+)\]/g const keys: Array = [] @@ -214,41 +217,41 @@ function parseUsingTemplate( let match: RegExpExecArray | null // Extract keys and static parts from the template - let pattern = '^' + let pattern = '^' // Start of string while ((match = regex.exec(template)) !== null) { if (match[1]) { keys.push(match[1]) } - // Add text between placeholders as static text in pattern + // Add text between placeholders as fixed text in the pattern pattern += - escapeRegExp(template.substring(lastIndex, match.index)) + '(.*?)' + escapeRegExp(template.substring(lastIndex, match.index)) + '([^\\-]*)' lastIndex = regex.lastIndex } - pattern += escapeRegExp(template.substring(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 null + return { params: null, isExactMatch: false } } // Map the extracted parts to their respective keys - // const result: { [key: string]: string } = {} - // keys.forEach((key, index) => { - // result[key] = encodePathParam(inputMatch[index + 1] || '', decodeCharMap) - // }) - const result: Array<{ key: string; value: string }> = [] + const params: Array<{ key: string; value: string }> = [] keys.forEach((key, index) => { - result.push({ + const value = inputMatch[index + 1] || '' + params.push({ key, - value: encodePathParam(inputMatch[index + 1] || '', decodeCharMap), + value: encodePathParam(value, decodeCharMap), }) }) - return result + return { params, isExactMatch } } // Helper function to escape special characters for use in regex @@ -282,7 +285,7 @@ export function interpolatePath({ // the splat/catch-all routes shouldn't have the '/' encoded out encodedParams[key] = isValueString ? encodeURI(value) : value } else if (isValueString && key.includes('[') && key.includes(']')) { - parseUsingTemplate(key, value, decodeCharMap)?.forEach( + parseSquareBrackets(key, value, decodeCharMap).params?.forEach( ({ key, value }) => { encodedParams[key] = encodePathParam(value, decodeCharMap) }, @@ -477,11 +480,22 @@ export function matchByPath( routeSegmentValue.includes('[') && routeSegmentValue.includes(']') ) { - parseUsingTemplate(routeSegmentValue, baseSegment.value)?.forEach( - ({ key, value }) => { - params[key] = decodeURIComponent(value) - }, + 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) { + return false + } + parsed.params?.forEach(({ key, value }) => { + params[key] = decodeURIComponent(value) + }) } else { const key = routeSegmentValue.substring(1) params[key] = decodeURIComponent(baseSegment.value) diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts index 32e8fbd905..865f2f7eb7 100644 --- a/packages/react-router/src/route.ts +++ b/packages/react-router/src/route.ts @@ -111,14 +111,14 @@ export interface SplatParams { export type ResolveParams = ParseSplatParams extends never - ? Record, string>, string> // Ensures extraction as strings + ? Record, string>, string> : Record, string>, string> & SplatParams export type ParseParamsFn = ( rawParams: ResolveParams, ) => TParams extends Record, any> ? TParams - : Record, string>, any> // Ensures the parameters are handled as desired + : Record, string>, any> export type StringifyParamsFn = ( params: TParams, From 77bf7fbad81e6b9da53369aaa06ef434572d60f1 Mon Sep 17 00:00:00 2001 From: CanRau Date: Mon, 11 Nov 2024 13:01:45 -0500 Subject: [PATCH 5/5] fix(react-router): scored matching --- packages/react-router/src/path.ts | 3 ++- packages/react-router/src/router.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/react-router/src/path.ts b/packages/react-router/src/path.ts index 39fbf82417..e522da9ed1 100644 --- a/packages/react-router/src/path.ts +++ b/packages/react-router/src/path.ts @@ -490,7 +490,8 @@ export function matchByPath( routeSegmentValue, baseSegment.value, ) - if (!parsed.isExactMatch && !matchLocation.fuzzy) { + // if (!parsed.isExactMatch && !matchLocation.fuzzy) { + if (!parsed.isExactMatch) { return false } parsed.params?.forEach(({ key, value }) => { diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index a687d64101..36a918234a 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -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]) {