Skip to content

Commit 2ab9c32

Browse files
committed
feat: new dynamic path matcher
1 parent 65a0940 commit 2ab9c32

File tree

6 files changed

+1860
-41
lines changed

6 files changed

+1860
-41
lines changed

packages/router/src/new-route-resolver/matcher-pattern.ts

Lines changed: 155 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MatcherName, MatcherQueryParams } from './matcher'
1+
import { decode, MatcherName, MatcherQueryParams } from './matcher'
22
import { EmptyParams, MatcherParamsFormatted } from './matcher-location'
33
import { miss } from './matchers/errors'
44

@@ -19,14 +19,28 @@ export interface MatcherPatternParams_Base<
1919
TIn = string,
2020
TOut extends MatcherParamsFormatted = MatcherParamsFormatted
2121
> {
22+
/**
23+
* Matches a serialized params value against the pattern.
24+
*
25+
* @param value - params value to parse
26+
* @throws {MatchMiss} if the value doesn't match
27+
* @returns parsed params
28+
*/
2229
match(value: TIn): TOut
30+
31+
/**
32+
* Build a serializable value from parsed params. Should apply encoding if the
33+
* returned value is a string (e.g path and hash should be encoded but query
34+
* shouldn't).
35+
*
36+
* @param value - params value to parse
37+
*/
2338
build(params: TOut): TIn
2439
}
2540

2641
export interface MatcherPatternPath<
27-
TParams extends MatcherParamsFormatted = // | undefined // | void // so it might be a bit more convenient // TODO: should we allow to not return anything? It's valid to spread null and undefined
28-
// | null
29-
MatcherParamsFormatted
42+
// TODO: should we allow to not return anything? It's valid to spread null and undefined
43+
TParams extends MatcherParamsFormatted = MatcherParamsFormatted // | null // | undefined // | void // so it might be a bit more convenient
3044
> extends MatcherPatternParams_Base<string, TParams> {}
3145

3246
export class MatcherPatternPathStatic
@@ -48,6 +62,143 @@ export class MatcherPatternPathStatic
4862
// example of a static matcher built at runtime
4963
// new MatcherPatternPathStatic('/')
5064

65+
export interface Param_GetSet<
66+
TIn extends string | string[] = string | string[],
67+
TOut = TIn
68+
> {
69+
get?: (value: NoInfer<TIn>) => TOut
70+
set?: (value: NoInfer<TOut>) => TIn
71+
}
72+
73+
export type ParamParser_Generic =
74+
| Param_GetSet<string, any>
75+
| Param_GetSet<string[], any>
76+
// TODO: these are possible values for optional params
77+
// | null | undefined
78+
79+
/**
80+
* Type safe helper to define a param parser.
81+
*
82+
* @param parser - the parser to define. Will be returned as is.
83+
*/
84+
/*! #__NO_SIDE_EFFECTS__ */
85+
export function defineParamParser<TOut, TIn extends string | string[]>(parser: {
86+
get?: (value: TIn) => TOut
87+
set?: (value: TOut) => TIn
88+
}): Param_GetSet<TIn, TOut> {
89+
return parser
90+
}
91+
92+
const PATH_PARAM_DEFAULT_GET = (value: string | string[]) => value
93+
const PATH_PARAM_DEFAULT_SET = (value: unknown) =>
94+
value && Array.isArray(value) ? value.map(String) : String(value)
95+
// TODO: `(value an null | undefined)` for types
96+
97+
/**
98+
* NOTE: I tried to make this generic and infer the types from the params but failed. This is what I tried:
99+
* ```ts
100+
* export type ParamsFromParsers<P extends Record<string, ParamParser_Generic>> = {
101+
* [K in keyof P]: P[K] extends Param_GetSet<infer TIn, infer TOut>
102+
* ? unknown extends TOut // if any or unknown, use the value of TIn, which defaults to string | string[]
103+
* ? TIn
104+
* : TOut
105+
* : never
106+
* }
107+
*
108+
* export class MatcherPatternPathDynamic<
109+
* ParamsParser extends Record<string, ParamParser_Generic>
110+
* > implements MatcherPatternPath<ParamsFromParsers<ParamsParser>>
111+
* {
112+
* private params: Record<string, Required<ParamParser_Generic>> = {}
113+
* constructor(
114+
* private re: RegExp,
115+
* params: ParamsParser,
116+
* public build: (params: ParamsFromParsers<ParamsParser>) => string
117+
* ) {}
118+
* ```
119+
* It ended up not working in one place or another. It could probably be fixed by
120+
*/
121+
122+
export type ParamsFromParsers<P extends Record<string, ParamParser_Generic>> = {
123+
[K in keyof P]: P[K] extends Param_GetSet<infer TIn, infer TOut>
124+
? unknown extends TOut // if any or unknown, use the value of TIn, which defaults to string | string[]
125+
? TIn
126+
: TOut
127+
: never
128+
}
129+
130+
export class MatcherPatternPathDynamic<
131+
TParams extends MatcherParamsFormatted = MatcherParamsFormatted
132+
> implements MatcherPatternPath<TParams>
133+
{
134+
private params: Record<string, Required<ParamParser_Generic>> = {}
135+
constructor(
136+
private re: RegExp,
137+
params: Record<keyof TParams, ParamParser_Generic>,
138+
public build: (params: TParams) => string,
139+
private opts: { repeat?: boolean; optional?: boolean } = {}
140+
) {
141+
for (const paramName in params) {
142+
const param = params[paramName]
143+
this.params[paramName] = {
144+
get: param.get || PATH_PARAM_DEFAULT_GET,
145+
// @ts-expect-error FIXME: should work
146+
set: param.set || PATH_PARAM_DEFAULT_SET,
147+
}
148+
}
149+
}
150+
151+
/**
152+
* Match path against the pattern and return
153+
*
154+
* @param path - path to match
155+
* @throws if the patch doesn't match
156+
* @returns matched decoded params
157+
*/
158+
match(path: string): TParams {
159+
const match = path.match(this.re)
160+
if (!match) {
161+
throw miss()
162+
}
163+
let i = 1 // index in match array
164+
const params = {} as TParams
165+
for (const paramName in this.params) {
166+
const currentParam = this.params[paramName]
167+
const currentMatch = match[i++]
168+
let value: string | null | string[] =
169+
this.opts.optional && currentMatch == null ? null : currentMatch
170+
value = this.opts.repeat && value ? value.split('/') : value
171+
172+
params[paramName as keyof typeof params] = currentParam.get(
173+
// @ts-expect-error: FIXME: the type of currentParam['get'] is wrong
174+
value && (Array.isArray(value) ? value.map(decode) : decode(value))
175+
) as (typeof params)[keyof typeof params]
176+
}
177+
178+
if (__DEV__ && i !== match.length) {
179+
console.warn(
180+
`Regexp matched ${match.length} params, but ${i} params are defined`
181+
)
182+
}
183+
return params
184+
}
185+
186+
// build(params: TParams): string {
187+
// let path = this.re.source
188+
// for (const param of this.params) {
189+
// const value = params[param.name as keyof TParams]
190+
// if (value == null) {
191+
// throw new Error(`Matcher build: missing param ${param.name}`)
192+
// }
193+
// path = path.replace(
194+
// /([^\\]|^)\([^?]*\)/,
195+
// `$1${encodeParam(param.set(value))}`
196+
// )
197+
// }
198+
// return path
199+
// }
200+
}
201+
51202
export interface MatcherPatternQuery<
52203
TParams extends MatcherParamsFormatted = MatcherParamsFormatted
53204
> extends MatcherPatternParams_Base<MatcherQueryParams, TParams> {}

0 commit comments

Comments
 (0)