Skip to content

Commit 5fa416a

Browse files
committed
feat: MatcherPatternPathCustom
1 parent 26bb388 commit 5fa416a

File tree

3 files changed

+213
-4
lines changed

3 files changed

+213
-4
lines changed

packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
22
import {
33
MatcherPatternPathStatic,
44
MatcherPatternPathStar,
5+
MatcherPatternPathCustom,
56
} from './matcher-pattern'
7+
import { pathEncoded } from '../resolver-abstract'
8+
import { invalid } from './errors'
69

710
describe('MatcherPatternPathStatic', () => {
811
describe('match()', () => {
@@ -100,3 +103,126 @@ describe('MatcherPatternPathStar', () => {
100103
})
101104
})
102105
})
106+
107+
describe('MatcherPatternPathCustom', () => {
108+
it('single param', () => {
109+
const pattern = new MatcherPatternPathCustom(
110+
/^\/teams\/([^/]+?)\/b$/i,
111+
{
112+
// all defaults
113+
teamId: {},
114+
},
115+
({ teamId }) => {
116+
if (typeof teamId !== 'string') {
117+
throw invalid('teamId must be a string')
118+
}
119+
return pathEncoded`/teams/${teamId}/b`
120+
}
121+
)
122+
123+
expect(pattern.match('/teams/123/b')).toEqual({
124+
teamId: '123',
125+
})
126+
expect(pattern.match('/teams/abc/b')).toEqual({
127+
teamId: 'abc',
128+
})
129+
expect(() => pattern.match('/teams/123/c')).toThrow()
130+
expect(() => pattern.match('/teams/123/b/c')).toThrow()
131+
expect(() => pattern.match('/teams')).toThrow()
132+
expect(() => pattern.match('/teams/')).toThrow()
133+
})
134+
135+
it('decodes single param', () => {
136+
const pattern = new MatcherPatternPathCustom(
137+
/^\/teams\/([^/]+?)$/i,
138+
{
139+
teamId: {},
140+
},
141+
({ teamId }) => {
142+
if (typeof teamId !== 'string') {
143+
throw invalid('teamId must be a string')
144+
}
145+
return pathEncoded`/teams/${teamId}`
146+
}
147+
)
148+
expect(pattern.match('/teams/a%20b')).toEqual({ teamId: 'a b' })
149+
expect(pattern.build({ teamId: 'a b' })).toBe('/teams/a%20b')
150+
})
151+
152+
it('optional param', () => {
153+
const pattern = new MatcherPatternPathCustom(
154+
/^\/teams(?:\/([^/]+?))?\/b$/i,
155+
{
156+
teamId: { optional: true },
157+
},
158+
({ teamId }) => {
159+
if (teamId != null && typeof teamId !== 'string') {
160+
throw invalid('teamId must be a string')
161+
}
162+
return teamId ? pathEncoded`/teams/${teamId}/b` : '/teams/b'
163+
}
164+
)
165+
166+
expect(pattern.match('/teams/b')).toEqual({ teamId: null })
167+
expect(pattern.match('/teams/123/b')).toEqual({ teamId: '123' })
168+
expect(() => pattern.match('/teams/123/c')).toThrow()
169+
expect(() => pattern.match('/teams/123/b/c')).toThrow()
170+
expect(pattern.build({ teamId: '123' })).toBe('/teams/123/b')
171+
expect(pattern.build({ teamId: null })).toBe('/teams/b')
172+
})
173+
174+
it('repeatable param', () => {
175+
const pattern = new MatcherPatternPathCustom(
176+
/^\/teams\/(.+?)\/b$/i,
177+
{
178+
teamId: { repeat: true },
179+
},
180+
({ teamId }) => {
181+
if (!Array.isArray(teamId)) {
182+
throw invalid('teamId must be an array')
183+
}
184+
return '/teams/' + teamId.join('/') + '/b'
185+
}
186+
)
187+
188+
expect(pattern.match('/teams/123/b')).toEqual({ teamId: ['123'] })
189+
expect(pattern.match('/teams/123/456/b')).toEqual({
190+
teamId: ['123', '456'],
191+
})
192+
expect(() => pattern.match('/teams/123/c')).toThrow()
193+
expect(() => pattern.match('/teams/123/b/c')).toThrow()
194+
expect(pattern.build({ teamId: ['123'] })).toBe('/teams/123/b')
195+
expect(pattern.build({ teamId: ['123', '456'] })).toBe('/teams/123/456/b')
196+
})
197+
198+
it('repeatable optional param', () => {
199+
const pattern = new MatcherPatternPathCustom(
200+
/^\/teams(?:\/(.+?))?\/b$/i,
201+
{
202+
teamId: { repeat: true, optional: true },
203+
},
204+
({ teamId }) => {
205+
if (!Array.isArray(teamId)) {
206+
throw invalid('teamId must be an array')
207+
}
208+
const joined = teamId.join('/')
209+
return teamId
210+
? '/teams' + (joined ? '/' + joined : '') + '/b'
211+
: '/teams/b'
212+
}
213+
)
214+
215+
expect(pattern.match('/teams/123/b')).toEqual({ teamId: ['123'] })
216+
expect(pattern.match('/teams/123/456/b')).toEqual({
217+
teamId: ['123', '456'],
218+
})
219+
expect(pattern.match('/teams/b')).toEqual({ teamId: [] })
220+
221+
expect(() => pattern.match('/teams/123/c')).toThrow()
222+
expect(() => pattern.match('/teams/123/b/c')).toThrow()
223+
224+
expect(pattern.build({ teamId: ['123'] })).toBe('/teams/123/b')
225+
expect(pattern.build({ teamId: ['123', '456'] })).toBe('/teams/123/456/b')
226+
expect(pattern.build({ teamId: [] })).toBe('/teams/b')
227+
})
228+
})

packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,12 @@ export class MatcherPatternPathStar
119119
// new MatcherPatternPathStatic('/team')
120120

121121
export interface Param_GetSet<
122-
TIn extends string | string[] = string | string[],
123-
TOut = TIn,
122+
TIn extends string | string[] | null | undefined =
123+
| string
124+
| string[]
125+
| null
126+
| undefined,
127+
TOut = string | string[] | null,
124128
> {
125129
get?: (value: NoInfer<TIn>) => TOut
126130
set?: (value: NoInfer<TOut>) => TIn
@@ -145,7 +149,13 @@ export function defineParamParser<TOut, TIn extends string | string[]>(parser: {
145149
return parser
146150
}
147151

148-
const PATH_PARAM_DEFAULT_GET = (value: string | string[]) => value
152+
interface IdFn {
153+
(v: undefined | null): null
154+
(v: string): string
155+
(v: string[]): string[]
156+
}
157+
158+
const PATH_PARAM_DEFAULT_GET = (value => value ?? null) as IdFn
149159
const PATH_PARAM_DEFAULT_SET = (value: unknown) =>
150160
value && Array.isArray(value) ? value.map(String) : String(value)
151161
// TODO: `(value an null | undefined)` for types
@@ -183,6 +193,79 @@ export type ParamsFromParsers<P extends Record<string, ParamParser_Generic>> = {
183193
: never
184194
}
185195

196+
/**
197+
* TODO: it should accept a dict of param parsers for each param and if they are repeatable and optional
198+
* The object order matters, they get matched in that order
199+
*/
200+
201+
interface MatcherPatternPathDynamicParam<
202+
TIn extends string | string[] | null | undefined =
203+
| string
204+
| string[]
205+
| null
206+
| undefined,
207+
TOut = string | string[] | null,
208+
> {
209+
repeat?: boolean
210+
optional?: boolean
211+
parser?: Param_GetSet<TIn, TOut>
212+
}
213+
214+
export class MatcherPatternPathCustom implements MatcherPatternPath {
215+
// private paramsKeys: string[]
216+
217+
constructor(
218+
readonly re: RegExp,
219+
readonly params: Record<string, MatcherPatternPathDynamicParam>,
220+
readonly build: (params: MatcherParamsFormatted) => string
221+
// A better version could be using all the parts to join them
222+
// .e.g ['users', 0, 'profile', 1] -> /users/123/profile/456
223+
// numbers are indexes of the params in the params object keys
224+
// readonly pathParts: Array<string | number>
225+
) {
226+
// this.paramsKeys = Object.keys(this.params)
227+
}
228+
229+
match(path: string): MatcherParamsFormatted {
230+
const match = path.match(this.re)
231+
if (!match) {
232+
throw miss()
233+
}
234+
const params = {} as MatcherParamsFormatted
235+
let i = 1 // index in match array
236+
for (const paramName in this.params) {
237+
const currentParam = this.params[paramName]
238+
// an optional group in the regexp will return undefined
239+
const currentMatch = match[i++] as string | undefined
240+
if (__DEV__ && !currentParam.optional && !currentMatch) {
241+
warn(
242+
`Unexpected undefined value for param "${paramName}". Regexp: ${String(this.re)}. path: "${path}". This is likely a bug.`
243+
)
244+
throw miss()
245+
}
246+
247+
const value = currentParam.repeat
248+
? (currentMatch?.split('/') || []).map(
249+
// using just decode makes the type inference fail
250+
v => decode(v)
251+
)
252+
: decode(currentMatch)
253+
254+
console.log(paramName, currentParam, value)
255+
256+
params[paramName] = (currentParam.parser?.get || (v => v ?? null))(value)
257+
}
258+
259+
if (__DEV__ && i !== match.length) {
260+
warn(
261+
`Regexp matched ${match.length} params, but ${i} params are defined. Found when matching "${path}" against ${String(this.re)}`
262+
)
263+
}
264+
265+
return params
266+
}
267+
}
268+
186269
/**
187270
* Matcher for dynamic paths, e.g. `/team/:id/:name`.
188271
* Supports one, one or zero, one or more and zero or more params.

packages/router/src/experimental/route-resolver/resolver-abstract.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export function pathEncoded(
228228
? params[i].map(encodeParam).join('/')
229229
: encodeParam(params[i]))
230230
)
231-
})
231+
}, '')
232232
}
233233
export interface ResolverLocationAsNamed {
234234
name: RecordName

0 commit comments

Comments
 (0)