diff --git a/packages/router/src/new-route-resolver/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts index 25d7c22ec..ad582bb8d 100644 --- a/packages/router/src/new-route-resolver/matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -1,4 +1,4 @@ -import { MatcherName, MatcherQueryParams } from './matcher' +import { decode, MatcherName, MatcherQueryParams } from './matcher' import { EmptyParams, MatcherParamsFormatted } from './matcher-location' import { miss } from './matchers/errors' @@ -19,14 +19,28 @@ export interface MatcherPatternParams_Base< TIn = string, TOut extends MatcherParamsFormatted = MatcherParamsFormatted > { + /** + * Matches a serialized params value against the pattern. + * + * @param value - params value to parse + * @throws {MatchMiss} if the value doesn't match + * @returns parsed params + */ match(value: TIn): TOut + + /** + * Build a serializable value from parsed params. Should apply encoding if the + * returned value is a string (e.g path and hash should be encoded but query + * shouldn't). + * + * @param value - params value to parse + */ build(params: TOut): TIn } export interface MatcherPatternPath< - 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 - // | null - MatcherParamsFormatted + // TODO: should we allow to not return anything? It's valid to spread null and undefined + TParams extends MatcherParamsFormatted = MatcherParamsFormatted // | null // | undefined // | void // so it might be a bit more convenient > extends MatcherPatternParams_Base {} export class MatcherPatternPathStatic @@ -48,6 +62,143 @@ export class MatcherPatternPathStatic // example of a static matcher built at runtime // new MatcherPatternPathStatic('/') +export interface Param_GetSet< + TIn extends string | string[] = string | string[], + TOut = TIn +> { + get?: (value: NoInfer) => TOut + set?: (value: NoInfer) => TIn +} + +export type ParamParser_Generic = + | Param_GetSet + | Param_GetSet +// TODO: these are possible values for optional params +// | null | undefined + +/** + * Type safe helper to define a param parser. + * + * @param parser - the parser to define. Will be returned as is. + */ +/*! #__NO_SIDE_EFFECTS__ */ +export function defineParamParser(parser: { + get?: (value: TIn) => TOut + set?: (value: TOut) => TIn +}): Param_GetSet { + return parser +} + +const PATH_PARAM_DEFAULT_GET = (value: string | string[]) => value +const PATH_PARAM_DEFAULT_SET = (value: unknown) => + value && Array.isArray(value) ? value.map(String) : String(value) +// TODO: `(value an null | undefined)` for types + +/** + * NOTE: I tried to make this generic and infer the types from the params but failed. This is what I tried: + * ```ts + * export type ParamsFromParsers

> = { + * [K in keyof P]: P[K] extends Param_GetSet + * ? unknown extends TOut // if any or unknown, use the value of TIn, which defaults to string | string[] + * ? TIn + * : TOut + * : never + * } + * + * export class MatcherPatternPathDynamic< + * ParamsParser extends Record + * > implements MatcherPatternPath> + * { + * private params: Record> = {} + * constructor( + * private re: RegExp, + * params: ParamsParser, + * public build: (params: ParamsFromParsers) => string + * ) {} + * ``` + * It ended up not working in one place or another. It could probably be fixed by + */ + +export type ParamsFromParsers

> = { + [K in keyof P]: P[K] extends Param_GetSet + ? unknown extends TOut // if any or unknown, use the value of TIn, which defaults to string | string[] + ? TIn + : TOut + : never +} + +export class MatcherPatternPathDynamic< + TParams extends MatcherParamsFormatted = MatcherParamsFormatted +> implements MatcherPatternPath +{ + private params: Record> = {} + constructor( + private re: RegExp, + params: Record, + public build: (params: TParams) => string, + private opts: { repeat?: boolean; optional?: boolean } = {} + ) { + for (const paramName in params) { + const param = params[paramName] + this.params[paramName] = { + get: param.get || PATH_PARAM_DEFAULT_GET, + // @ts-expect-error FIXME: should work + set: param.set || PATH_PARAM_DEFAULT_SET, + } + } + } + + /** + * Match path against the pattern and return + * + * @param path - path to match + * @throws if the patch doesn't match + * @returns matched decoded params + */ + match(path: string): TParams { + const match = path.match(this.re) + if (!match) { + throw miss() + } + let i = 1 // index in match array + const params = {} as TParams + for (const paramName in this.params) { + const currentParam = this.params[paramName] + const currentMatch = match[i++] + let value: string | null | string[] = + this.opts.optional && currentMatch == null ? null : currentMatch + value = this.opts.repeat && value ? value.split('/') : value + + params[paramName as keyof typeof params] = currentParam.get( + // @ts-expect-error: FIXME: the type of currentParam['get'] is wrong + value && (Array.isArray(value) ? value.map(decode) : decode(value)) + ) as (typeof params)[keyof typeof params] + } + + if (__DEV__ && i !== match.length) { + console.warn( + `Regexp matched ${match.length} params, but ${i} params are defined` + ) + } + return params + } + + // build(params: TParams): string { + // let path = this.re.source + // for (const param of this.params) { + // const value = params[param.name as keyof TParams] + // if (value == null) { + // throw new Error(`Matcher build: missing param ${param.name}`) + // } + // path = path.replace( + // /([^\\]|^)\([^?]*\)/, + // `$1${encodeParam(param.set(value))}` + // ) + // } + // return path + // } +} + export interface MatcherPatternQuery< TParams extends MatcherParamsFormatted = MatcherParamsFormatted > extends MatcherPatternParams_Base {} diff --git a/packages/router/src/new-route-resolver/matcher-resolve.spec.ts b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts new file mode 100644 index 000000000..b4799bbec --- /dev/null +++ b/packages/router/src/new-route-resolver/matcher-resolve.spec.ts @@ -0,0 +1,1492 @@ +import { createRouterMatcher, normalizeRouteRecord } from '../matcher' +import { RouteComponent, RouteRecordRaw, MatcherLocation } from '../types' +import { MatcherLocationNormalizedLoose } from '../../__tests__/utils' +import { defineComponent } from 'vue' +import { START_LOCATION_NORMALIZED } from '../location' +import { describe, expect, it } from 'vitest' +import { mockWarn } from '../../__tests__/vitest-mock-warn' +import { + createCompiledMatcher, + MatcherLocationRaw, + MatcherRecordRaw, + NEW_LocationResolved, +} from './matcher' +import { PathParams, tokensToParser } from '../matcher/pathParserRanker' +import { tokenizePath } from '../matcher/pathTokenizer' +import { miss } from './matchers/errors' +import { MatcherPatternPath } from './matcher-pattern' + +// for raw route record +const component: RouteComponent = defineComponent({}) +// for normalized route records +const components = { default: component } + +function compileRouteRecord( + record: RouteRecordRaw, + parentRecord?: RouteRecordRaw +): MatcherRecordRaw { + // we adapt the path to ensure they are absolute + // TODO: aliases? they could be handled directly in the path matcher + const path = record.path.startsWith('/') + ? record.path + : (parentRecord?.path || '') + record.path + record.path = path + const parser = tokensToParser(tokenizePath(record.path), { + // start: true, + end: record.end, + sensitive: record.sensitive, + strict: record.strict, + }) + + return { + name: record.name, + + path: { + match(value) { + const params = parser.parse(value) + if (params) { + return params + } + throw miss() + }, + build(params) { + // TODO: normalize params? + return parser.stringify(params) + }, + } satisfies MatcherPatternPath, + + children: record.children?.map(childRecord => + compileRouteRecord(childRecord, record) + ), + } +} + +describe('RouterMatcher.resolve', () => { + mockWarn() + type Matcher = ReturnType + type MatcherResolvedLocation = ReturnType + + const START_LOCATION: NEW_LocationResolved = { + name: Symbol('START'), + fullPath: '/', + path: '/', + params: {}, + query: {}, + hash: '', + matched: [], + } + + function isMatcherLocationResolved( + location: unknown + ): location is NEW_LocationResolved { + return !!( + location && + typeof location === 'object' && + 'matched' in location && + 'fullPath' in location && + Array.isArray(location.matched) + ) + } + + // TODO: rework with object param for clarity + + function assertRecordMatch( + record: RouteRecordRaw | RouteRecordRaw[], + toLocation: MatcherLocationRaw, + expectedLocation: Partial, + fromLocation: + | NEW_LocationResolved + | Exclude + | `/${string}` = START_LOCATION + ) { + const records = (Array.isArray(record) ? record : [record]).map( + (record): MatcherRecordRaw => compileRouteRecord(record) + ) + const matcher = createCompiledMatcher() + for (const record of records) { + matcher.addRoute(record) + } + + const resolved: MatcherResolvedLocation = { + // FIXME: to add later + // meta: records[0].meta || {}, + path: + typeof toLocation === 'string' ? toLocation : toLocation.path || '/', + name: expect.any(Symbol) as symbol, + matched: [], // FIXME: build up + params: (typeof toLocation === 'object' && toLocation.params) || {}, + ...expectedLocation, + } + + Object.defineProperty(resolved, 'matched', { + writable: true, + configurable: true, + enumerable: false, + value: [], + }) + + fromLocation = isMatcherLocationResolved(fromLocation) + ? fromLocation + : matcher.resolve(fromLocation) + + expect(matcher.resolve(toLocation, fromLocation)).toMatchObject({ + // avoid undesired properties + query: {}, + hash: '', + ...resolved, + }) + } + + function _assertRecordMatch( + record: RouteRecordRaw | RouteRecordRaw[], + location: MatcherLocationRaw, + resolved: Partial, + start: MatcherLocation = START_LOCATION_NORMALIZED + ) { + record = Array.isArray(record) ? record : [record] + const matcher = createRouterMatcher(record, {}) + + if (!('meta' in resolved)) { + resolved.meta = record[0].meta || {} + } + + if (!('name' in resolved)) { + resolved.name = undefined + } + + // add location if provided as it should be the same value + if ('path' in location && !('path' in resolved)) { + resolved.path = location.path + } + + if ('redirect' in record) { + throw new Error('not handled') + } else { + // use one single record + if (!resolved.matched) resolved.matched = record.map(normalizeRouteRecord) + // allow passing an expect.any(Array) + else if (Array.isArray(resolved.matched)) + resolved.matched = resolved.matched.map(m => ({ + ...normalizeRouteRecord(m as any), + aliasOf: m.aliasOf, + })) + } + + // allows not passing params + resolved.params = + resolved.params || ('params' in location ? location.params : {}) + + const startCopy: MatcherLocation = { + ...start, + matched: start.matched.map(m => ({ + ...normalizeRouteRecord(m), + aliasOf: m.aliasOf, + })) as MatcherLocation['matched'], + } + + // make matched non enumerable + Object.defineProperty(startCopy, 'matched', { enumerable: false }) + + const result = matcher.resolve(location, startCopy) + expect(result).toEqual(resolved) + } + + /** + * + * @param record - Record or records we are testing the matcher against + * @param location - location we want to resolve against + * @param [start] Optional currentLocation used when resolving + * @returns error + */ + function assertErrorMatch( + record: RouteRecordRaw | RouteRecordRaw[], + location: MatcherLocationRaw, + start: MatcherLocation = START_LOCATION_NORMALIZED + ) { + assertRecordMatch(record, location, {}, start) + } + + describe.skip('LocationAsPath', () => { + it('resolves a normal path', () => { + assertRecordMatch({ path: '/', name: 'Home', components }, '/', { + name: 'Home', + path: '/', + params: {}, + }) + }) + + it('resolves a normal path without name', () => { + assertRecordMatch( + { path: '/', components }, + { path: '/' }, + { name: undefined, path: '/', params: {} } + ) + }) + + it('resolves a path with params', () => { + assertRecordMatch( + { path: '/users/:id', name: 'User', components }, + { path: '/users/posva' }, + { name: 'User', params: { id: 'posva' } } + ) + }) + + it('resolves an array of params for a repeatable params', () => { + assertRecordMatch( + { path: '/a/:p+', name: 'a', components }, + { name: 'a', params: { p: ['b', 'c', 'd'] } }, + { name: 'a', path: '/a/b/c/d', params: { p: ['b', 'c', 'd'] } } + ) + }) + + it('resolves single params for a repeatable params', () => { + assertRecordMatch( + { path: '/a/:p+', name: 'a', components }, + { name: 'a', params: { p: 'b' } }, + { name: 'a', path: '/a/b', params: { p: 'b' } } + ) + }) + + it('keeps repeated params as a single one when provided through path', () => { + assertRecordMatch( + { path: '/a/:p+', name: 'a', components }, + { path: '/a/b/c' }, + { name: 'a', params: { p: ['b', 'c'] } } + ) + }) + + it('resolves a path with multiple params', () => { + assertRecordMatch( + { path: '/users/:id/:other', name: 'User', components }, + { path: '/users/posva/hey' }, + { name: 'User', params: { id: 'posva', other: 'hey' } } + ) + }) + + it('resolves a path with multiple params but no name', () => { + assertRecordMatch( + { path: '/users/:id/:other', components }, + { path: '/users/posva/hey' }, + { name: undefined, params: { id: 'posva', other: 'hey' } } + ) + }) + + it('returns an empty match when the path does not exist', () => { + assertRecordMatch( + { path: '/', components }, + { path: '/foo' }, + { name: undefined, params: {}, path: '/foo', matched: [] } + ) + }) + + it('allows an optional trailing slash', () => { + assertRecordMatch( + { path: '/home/', name: 'Home', components }, + { path: '/home/' }, + { name: 'Home', path: '/home/', matched: expect.any(Array) } + ) + }) + + it('allows an optional trailing slash with optional param', () => { + assertRecordMatch( + { path: '/:a', components, name: 'a' }, + { path: '/a/' }, + { path: '/a/', params: { a: 'a' }, name: 'a' } + ) + assertRecordMatch( + { path: '/a/:a', components, name: 'a' }, + { path: '/a/a/' }, + { path: '/a/a/', params: { a: 'a' }, name: 'a' } + ) + }) + + it('allows an optional trailing slash with missing optional param', () => { + assertRecordMatch( + { path: '/:a?', components, name: 'a' }, + { path: '/' }, + { path: '/', params: { a: '' }, name: 'a' } + ) + assertRecordMatch( + { path: '/a/:a?', components, name: 'a' }, + { path: '/a/' }, + { path: '/a/', params: { a: '' }, name: 'a' } + ) + }) + + it('keeps required trailing slash (strict: true)', () => { + const record = { + path: '/home/', + name: 'Home', + components, + options: { strict: true }, + } + assertErrorMatch(record, { path: '/home' }) + assertRecordMatch( + record, + { path: '/home/' }, + { name: 'Home', path: '/home/', matched: expect.any(Array) } + ) + }) + + it('rejects a trailing slash when strict', () => { + const record = { + path: '/home', + name: 'Home', + components, + options: { strict: true }, + } + assertRecordMatch( + record, + { path: '/home' }, + { name: 'Home', path: '/home', matched: expect.any(Array) } + ) + assertErrorMatch(record, { path: '/home/' }) + }) + }) + + describe('LocationAsName', () => { + it('matches a name', () => { + assertRecordMatch( + { path: '/home', name: 'Home', components }, + // TODO: allow a name only without the params? + { name: 'Home', params: {} }, + { name: 'Home', path: '/home' } + ) + }) + + it('matches a name and fill params', () => { + assertRecordMatch( + { path: '/users/:id/m/:role', name: 'UserEdit', components }, + { name: 'UserEdit', params: { id: 'posva', role: 'admin' } }, + { + name: 'UserEdit', + path: '/users/posva/m/admin', + params: { id: 'posva', role: 'admin' }, + } + ) + }) + + it('throws if the named route does not exists', () => { + expect(() => + assertErrorMatch( + { path: '/', components }, + { name: 'Home', params: {} } + ) + ).toThrowError('Matcher "Home" not found') + }) + + it('merges params', () => { + assertRecordMatch( + { path: '/:a/:b', name: 'p', components }, + { params: { b: 'b' } }, + { name: 'p', path: '/A/b', params: { a: 'A', b: 'b' } }, + '/A/B' + ) + }) + + // TODO: new matcher no longer allows implicit param merging + it.todo('only keep existing params', () => { + assertRecordMatch( + { path: '/:a/:b', name: 'p', components }, + { name: 'p', params: { b: 'b' } }, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + '/a/c' + ) + }) + + // TODO: implement parent children + it.todo('keep optional params from parent record', () => { + const Child_A = { path: 'a', name: 'child_a', components } + const Child_B = { path: 'b', name: 'child_b', components } + const Parent = { + path: '/:optional?/parent', + name: 'parent', + components, + children: [Child_A, Child_B], + } + assertRecordMatch( + Parent, + { name: 'child_b' }, + { + name: 'child_b', + path: '/foo/parent/b', + params: { optional: 'foo' }, + matched: [ + Parent as any, + { + ...Child_B, + path: `${Parent.path}/${Child_B.path}`, + }, + ], + }, + { + params: { optional: 'foo' }, + path: '/foo/parent/a', + matched: [], + meta: {}, + name: undefined, + } + ) + }) + + // TODO: check if needed by the active matching, if not just test that the param is dropped + it.todo('discards non existent params', () => { + assertRecordMatch( + { path: '/', name: 'home', components }, + { name: 'home', params: { a: 'a', b: 'b' } }, + { name: 'home', path: '/', params: {} } + ) + expect('invalid param(s) "a", "b" ').toHaveBeenWarned() + assertRecordMatch( + { path: '/:b', name: 'a', components }, + { name: 'a', params: { a: 'a', b: 'b' } }, + { name: 'a', path: '/b', params: { b: 'b' } } + ) + expect('invalid param(s) "a"').toHaveBeenWarned() + }) + + it('drops optional params in absolute location', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { name: 'p', params: { a: 'b' } }, + { name: 'p', path: '/b', params: { a: 'b' } } + ) + }) + + it('keeps optional params passed as empty strings', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { name: 'p', params: { a: 'b', b: '' } }, + { name: 'p', path: '/b', params: { a: 'b', b: '' } } + ) + }) + + it('resolves root path with optional params', () => { + assertRecordMatch( + { path: '/:tab?', name: 'h', components }, + { name: 'h', params: {} }, + { name: 'h', path: '/', params: {} } + ) + assertRecordMatch( + { path: '/:tab?/:other?', name: 'h', components }, + { name: 'h', params: {} }, + { name: 'h', path: '/', params: {} } + ) + }) + }) + + describe.skip('LocationAsRelative', () => { + it('warns if a path isn not absolute', () => { + const record = { + path: '/parent', + components, + } + const matcher = createRouterMatcher([record], {}) + matcher.resolve( + { path: 'two' }, + { + path: '/parent/one', + name: undefined, + params: {}, + matched: [] as any, + meta: {}, + } + ) + expect('received "two"').toHaveBeenWarned() + }) + + it('matches with nothing', () => { + const record = { path: '/home', name: 'Home', components } + assertRecordMatch( + record, + {}, + { name: 'Home', path: '/home' }, + { + name: 'Home', + params: {}, + path: '/home', + matched: [record] as any, + meta: {}, + } + ) + }) + + it('replace params even with no name', () => { + const record = { path: '/users/:id/m/:role', components } + assertRecordMatch( + record, + { params: { id: 'posva', role: 'admin' } }, + { name: undefined, path: '/users/posva/m/admin' }, + { + path: '/users/ed/m/user', + name: undefined, + params: { id: 'ed', role: 'user' }, + matched: [record] as any, + meta: {}, + } + ) + }) + + it('replace params', () => { + const record = { + path: '/users/:id/m/:role', + name: 'UserEdit', + components, + } + assertRecordMatch( + record, + { params: { id: 'posva', role: 'admin' } }, + { name: 'UserEdit', path: '/users/posva/m/admin' }, + { + path: '/users/ed/m/user', + name: 'UserEdit', + params: { id: 'ed', role: 'user' }, + matched: [], + meta: {}, + } + ) + }) + + it('keep params if not provided', () => { + const record = { + path: '/users/:id/m/:role', + name: 'UserEdit', + components, + } + assertRecordMatch( + record, + {}, + { + name: 'UserEdit', + path: '/users/ed/m/user', + params: { id: 'ed', role: 'user' }, + }, + { + path: '/users/ed/m/user', + name: 'UserEdit', + params: { id: 'ed', role: 'user' }, + matched: [record] as any, + meta: {}, + } + ) + }) + + it('keep params if not provided even with no name', () => { + const record = { path: '/users/:id/m/:role', components } + assertRecordMatch( + record, + {}, + { + name: undefined, + path: '/users/ed/m/user', + params: { id: 'ed', role: 'user' }, + }, + { + path: '/users/ed/m/user', + name: undefined, + params: { id: 'ed', role: 'user' }, + matched: [record] as any, + meta: {}, + } + ) + }) + + it('merges params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { params: { b: 'b' } }, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + { + name: 'p', + params: { a: 'a' }, + path: '/a', + matched: [], + meta: {}, + } + ) + }) + + it('keep optional params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + {}, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + { + name: 'p', + params: { a: 'a', b: 'b' }, + path: '/a/b', + matched: [], + meta: {}, + } + ) + }) + + it('merges optional params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { params: { a: 'c' } }, + { name: 'p', path: '/c/b', params: { a: 'c', b: 'b' } }, + { + name: 'p', + params: { a: 'a', b: 'b' }, + path: '/a/b', + matched: [], + meta: {}, + } + ) + }) + + it('throws if the current named route does not exists', () => { + const record = { path: '/', components } + const start = { + name: 'home', + params: {}, + path: '/', + matched: [record], + } + // the property should be non enumerable + Object.defineProperty(start, 'matched', { enumerable: false }) + expect( + assertErrorMatch( + record, + { params: { a: 'foo' } }, + { + ...start, + matched: start.matched.map(normalizeRouteRecord), + meta: {}, + } + ) + ).toMatchSnapshot() + }) + + it('avoids records with children without a component nor name', () => { + assertErrorMatch( + { + path: '/articles', + children: [{ path: ':id', components }], + }, + { path: '/articles' } + ) + }) + + it('avoid deeply nested records with children without a component nor name', () => { + assertErrorMatch( + { + path: '/app', + components, + children: [ + { + path: '/articles', + children: [{ path: ':id', components }], + }, + ], + }, + { path: '/articles' } + ) + }) + + it('can reach a named route with children and no component if named', () => { + assertRecordMatch( + { + path: '/articles', + name: 'ArticlesParent', + children: [{ path: ':id', components }], + }, + { name: 'ArticlesParent' }, + { name: 'ArticlesParent', path: '/articles' } + ) + }) + }) + + describe.skip('alias', () => { + it('resolves an alias', () => { + assertRecordMatch( + { + path: '/', + alias: '/home', + name: 'Home', + components, + meta: { foo: true }, + }, + { path: '/home' }, + { + name: 'Home', + path: '/home', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/home', + name: 'Home', + components, + aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + meta: { foo: true }, + }, + ], + } + ) + }) + + it('multiple aliases', () => { + const record = { + path: '/', + alias: ['/home', '/start'], + name: 'Home', + components, + meta: { foo: true }, + } + + assertRecordMatch( + record, + { path: '/' }, + { + name: 'Home', + path: '/', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/', + name: 'Home', + components, + aliasOf: undefined, + meta: { foo: true }, + }, + ], + } + ) + assertRecordMatch( + record, + { path: '/home' }, + { + name: 'Home', + path: '/home', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/home', + name: 'Home', + components, + aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + meta: { foo: true }, + }, + ], + } + ) + assertRecordMatch( + record, + { path: '/start' }, + { + name: 'Home', + path: '/start', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/start', + name: 'Home', + components, + aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + meta: { foo: true }, + }, + ], + } + ) + }) + + it('resolves the original record by name', () => { + assertRecordMatch( + { + path: '/', + alias: '/home', + name: 'Home', + components, + meta: { foo: true }, + }, + { name: 'Home' }, + { + name: 'Home', + path: '/', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/', + name: 'Home', + components, + aliasOf: undefined, + meta: { foo: true }, + }, + ], + } + ) + }) + + it('resolves an alias with children to the alias when using the path', () => { + const children = [{ path: 'one', component, name: 'nested' }] + assertRecordMatch( + { + path: '/parent', + alias: '/p', + component, + children, + }, + { path: '/p/one' }, + { + path: '/p/one', + name: 'nested', + params: {}, + matched: [ + { + path: '/p', + children, + components, + aliasOf: expect.objectContaining({ path: '/parent' }), + }, + { + path: '/p/one', + name: 'nested', + components, + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }, + ], + } + ) + }) + + describe('nested aliases', () => { + const children = [ + { + path: 'one', + component, + name: 'nested', + alias: 'o', + children: [ + { path: 'two', alias: 't', name: 'nestednested', component }, + ], + }, + { + path: 'other', + alias: 'otherAlias', + component, + name: 'other', + }, + ] + const record = { + path: '/parent', + name: 'parent', + alias: '/p', + component, + children, + } + + it('resolves the parent as an alias', () => { + assertRecordMatch( + record, + { path: '/p' }, + expect.objectContaining({ + path: '/p', + name: 'parent', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + ], + }) + ) + }) + + describe('multiple children', () => { + // tests concerning the /parent/other path and its aliases + + it('resolves the alias parent', () => { + assertRecordMatch( + record, + { path: '/p/other' }, + expect.objectContaining({ + path: '/p/other', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/other', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + + it('resolves the alias child', () => { + assertRecordMatch( + record, + { path: '/parent/otherAlias' }, + expect.objectContaining({ + path: '/parent/otherAlias', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/otherAlias', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + + it('resolves the alias parent and child', () => { + assertRecordMatch( + record, + { path: '/p/otherAlias' }, + expect.objectContaining({ + path: '/p/otherAlias', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/otherAlias', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + }) + + it('resolves the original one with no aliases', () => { + assertRecordMatch( + record, + { path: '/parent/one/two' }, + expect.objectContaining({ + path: '/parent/one/two', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one/two', + aliasOf: undefined, + }), + ], + }) + ) + }) + + it.todo('resolves when parent is an alias and child has an absolute path') + + it('resolves when parent is an alias', () => { + assertRecordMatch( + record, + { path: '/p/one/two' }, + expect.objectContaining({ + path: '/p/one/two', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/one', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/p/one/two', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves a different child when parent is an alias', () => { + assertRecordMatch( + record, + { path: '/p/other' }, + expect.objectContaining({ + path: '/p/other', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/other', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + + it('resolves when the first child is an alias', () => { + assertRecordMatch( + record, + { path: '/parent/o/two' }, + expect.objectContaining({ + path: '/parent/o/two', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/o', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/parent/o/two', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when the second child is an alias', () => { + assertRecordMatch( + record, + { path: '/parent/one/t' }, + expect.objectContaining({ + path: '/parent/one/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when the two last children are aliases', () => { + assertRecordMatch( + record, + { path: '/parent/o/t' }, + expect.objectContaining({ + path: '/parent/o/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/o', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/parent/o/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when all are aliases', () => { + assertRecordMatch( + record, + { path: '/p/o/t' }, + expect.objectContaining({ + path: '/p/o/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/o', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/p/o/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when first and last are aliases', () => { + assertRecordMatch( + record, + { path: '/p/one/t' }, + expect.objectContaining({ + path: '/p/one/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/one', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/p/one/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + }) + + it('resolves the original path of the named children of a route with an alias', () => { + const children = [{ path: 'one', component, name: 'nested' }] + assertRecordMatch( + { + path: '/parent', + alias: '/p', + component, + children, + }, + { name: 'nested' }, + { + path: '/parent/one', + name: 'nested', + params: {}, + matched: [ + { + path: '/parent', + children, + components, + aliasOf: undefined, + }, + { path: '/parent/one', name: 'nested', components }, + ], + } + ) + }) + }) + + describe.skip('children', () => { + const ChildA = { path: 'a', name: 'child-a', components } + const ChildB = { path: 'b', name: 'child-b', components } + const ChildC = { path: 'c', name: 'child-c', components } + const ChildD = { path: '/absolute', name: 'absolute', components } + const ChildWithParam = { path: ':p', name: 'child-params', components } + const NestedChildWithParam = { + ...ChildWithParam, + name: 'nested-child-params', + } + const NestedChildA = { ...ChildA, name: 'nested-child-a' } + const NestedChildB = { ...ChildB, name: 'nested-child-b' } + const NestedChildC = { ...ChildC, name: 'nested-child-c' } + const Nested = { + path: 'nested', + name: 'nested', + components, + children: [NestedChildA, NestedChildB, NestedChildC], + } + const NestedWithParam = { + path: 'nested/:n', + name: 'nested', + components, + children: [NestedChildWithParam], + } + + it('resolves children', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [ChildA, ChildB, ChildC], + } + assertRecordMatch( + Foo, + { path: '/foo/b' }, + { + name: 'child-b', + path: '/foo/b', + params: {}, + matched: [Foo, { ...ChildB, path: `${Foo.path}/${ChildB.path}` }], + } + ) + }) + + it('resolves children with empty paths', () => { + const Nested = { path: '', name: 'nested', components } + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { path: '/foo' }, + { + name: 'nested', + path: '/foo', + params: {}, + matched: [Foo as any, { ...Nested, path: `${Foo.path}` }], + } + ) + }) + + it('resolves nested children with empty paths', () => { + const NestedNested = { path: '', name: 'nested', components } + const Nested = { + path: '', + name: 'nested-nested', + components, + children: [NestedNested], + } + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { path: '/foo' }, + { + name: 'nested', + path: '/foo', + params: {}, + matched: [ + Foo as any, + { ...Nested, path: `${Foo.path}` }, + { ...NestedNested, path: `${Foo.path}` }, + ], + } + ) + }) + + it('resolves nested children', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { path: '/foo/nested/a' }, + { + name: 'nested-child-a', + path: '/foo/nested/a', + params: {}, + matched: [ + Foo as any, + { ...Nested, path: `${Foo.path}/${Nested.path}` }, + { + ...NestedChildA, + path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + }, + ], + } + ) + }) + + it('resolves nested children with named location', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { name: 'nested-child-a' }, + { + name: 'nested-child-a', + path: '/foo/nested/a', + params: {}, + matched: [ + Foo as any, + { ...Nested, path: `${Foo.path}/${Nested.path}` }, + { + ...NestedChildA, + path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + }, + ], + } + ) + }) + + it('resolves nested children with relative location', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + {}, + { + name: 'nested-child-a', + path: '/foo/nested/a', + params: {}, + matched: [ + Foo as any, + { ...Nested, path: `${Foo.path}/${Nested.path}` }, + { + ...NestedChildA, + path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + }, + ], + }, + { + name: 'nested-child-a', + matched: [], + params: {}, + path: '/foo/nested/a', + meta: {}, + } + ) + }) + + it('resolves nested children with params', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [NestedWithParam], + } + assertRecordMatch( + Foo, + { path: '/foo/nested/a/b' }, + { + name: 'nested-child-params', + path: '/foo/nested/a/b', + params: { p: 'b', n: 'a' }, + matched: [ + Foo as any, + { + ...NestedWithParam, + path: `${Foo.path}/${NestedWithParam.path}`, + }, + { + ...NestedChildWithParam, + path: `${Foo.path}/${NestedWithParam.path}/${NestedChildWithParam.path}`, + }, + ], + } + ) + }) + + it('resolves nested children with params with named location', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [NestedWithParam], + } + assertRecordMatch( + Foo, + { name: 'nested-child-params', params: { p: 'a', n: 'b' } }, + { + name: 'nested-child-params', + path: '/foo/nested/b/a', + params: { p: 'a', n: 'b' }, + matched: [ + Foo as any, + { + ...NestedWithParam, + path: `${Foo.path}/${NestedWithParam.path}`, + }, + { + ...NestedChildWithParam, + path: `${Foo.path}/${NestedWithParam.path}/${NestedChildWithParam.path}`, + }, + ], + } + ) + }) + + it('resolves absolute path children', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [ChildA, ChildD], + } + assertRecordMatch( + Foo, + { path: '/absolute' }, + { + name: 'absolute', + path: '/absolute', + params: {}, + matched: [Foo, ChildD], + } + ) + }) + + it('resolves children with root as the parent', () => { + const Nested = { path: 'nested', name: 'nested', components } + const Parent = { + path: '/', + name: 'parent', + components, + children: [Nested], + } + assertRecordMatch( + Parent, + { path: '/nested' }, + { + name: 'nested', + path: '/nested', + params: {}, + matched: [Parent as any, { ...Nested, path: `/nested` }], + } + ) + }) + + it('resolves children with parent with trailing slash', () => { + const Nested = { path: 'nested', name: 'nested', components } + const Parent = { + path: '/parent/', + name: 'parent', + components, + children: [Nested], + } + assertRecordMatch( + Parent, + { path: '/parent/nested' }, + { + name: 'nested', + path: '/parent/nested', + params: {}, + matched: [Parent as any, { ...Nested, path: `/parent/nested` }], + } + ) + }) + }) +}) diff --git a/packages/router/src/new-route-resolver/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts index c15561f53..f508fe113 100644 --- a/packages/router/src/new-route-resolver/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -1,10 +1,17 @@ import { describe, expect, it } from 'vitest' -import { createCompiledMatcher, NO_MATCH_LOCATION } from './matcher' +import { + createCompiledMatcher, + NO_MATCH_LOCATION, + pathEncoded, +} from './matcher' import { MatcherPatternParams_Base, MatcherPattern, MatcherPatternPath, MatcherPatternQuery, + MatcherPatternPathStatic, + MatcherPatternPathDynamic, + defineParamParser, } from './matcher-pattern' import { miss } from './matchers/errors' import { EmptyParams } from './matcher-location' @@ -73,7 +80,52 @@ const USER_ID_ROUTE = { path: USER_ID_PATH_PATTERN_MATCHER, } satisfies MatcherPattern -describe('Matcher', () => { +describe('RouterMatcher', () => { + describe('new matchers', () => { + it('static path', () => { + const matcher = createCompiledMatcher([ + { path: new MatcherPatternPathStatic('/') }, + { path: new MatcherPatternPathStatic('/users') }, + ]) + + expect(matcher.resolve('/')).toMatchObject({ + fullPath: '/', + path: '/', + params: {}, + query: {}, + hash: '', + }) + + expect(matcher.resolve('/users')).toMatchObject({ + fullPath: '/users', + path: '/users', + params: {}, + query: {}, + hash: '', + }) + }) + + it('dynamic path', () => { + const matcher = createCompiledMatcher([ + { + path: new MatcherPatternPathDynamic<{ id: string }>( + /^\/users\/([^\/]+)$/, + { + id: {}, + }, + ({ id }) => pathEncoded`/users/${id}` + ), + }, + ]) + + expect(matcher.resolve('/users/1')).toMatchObject({ + fullPath: '/users/1', + path: '/users/1', + params: { id: '1' }, + }) + }) + }) + describe('adding and removing', () => { it('add static path', () => { const matcher = createCompiledMatcher() @@ -87,10 +139,9 @@ describe('Matcher', () => { }) describe('resolve()', () => { - describe('absolute locationss as strings', () => { + describe('absolute locations as strings', () => { it('resolves string locations with no params', () => { - const matcher = createCompiledMatcher() - matcher.addRoute(EMPTY_PATH_ROUTE) + const matcher = createCompiledMatcher([EMPTY_PATH_ROUTE]) expect(matcher.resolve('/?a=a&b=b#h')).toMatchObject({ path: '/', @@ -113,8 +164,7 @@ describe('Matcher', () => { }) it('resolves string locations with params', () => { - const matcher = createCompiledMatcher() - matcher.addRoute(USER_ID_ROUTE) + const matcher = createCompiledMatcher([USER_ID_ROUTE]) expect(matcher.resolve('/users/1?a=a&b=b#h')).toMatchObject({ path: '/users/1', @@ -131,11 +181,12 @@ describe('Matcher', () => { }) it('resolve string locations with query', () => { - const matcher = createCompiledMatcher() - matcher.addRoute({ - path: ANY_PATH_PATTERN_MATCHER, - query: PAGE_QUERY_PATTERN_MATCHER, - }) + const matcher = createCompiledMatcher([ + { + path: ANY_PATH_PATTERN_MATCHER, + query: PAGE_QUERY_PATTERN_MATCHER, + }, + ]) expect(matcher.resolve('/foo?page=100&b=b#h')).toMatchObject({ params: { page: 100 }, @@ -149,11 +200,12 @@ describe('Matcher', () => { }) it('resolves string locations with hash', () => { - const matcher = createCompiledMatcher() - matcher.addRoute({ - path: ANY_PATH_PATTERN_MATCHER, - hash: ANY_HASH_PATTERN_MATCHER, - }) + const matcher = createCompiledMatcher([ + { + path: ANY_PATH_PATTERN_MATCHER, + hash: ANY_HASH_PATTERN_MATCHER, + }, + ]) expect(matcher.resolve('/foo?a=a&b=b#bar')).toMatchObject({ hash: '#bar', @@ -164,12 +216,13 @@ describe('Matcher', () => { }) it('combines path, query and hash params', () => { - const matcher = createCompiledMatcher() - matcher.addRoute({ - path: USER_ID_PATH_PATTERN_MATCHER, - query: PAGE_QUERY_PATTERN_MATCHER, - hash: ANY_HASH_PATTERN_MATCHER, - }) + const matcher = createCompiledMatcher([ + { + path: USER_ID_PATH_PATTERN_MATCHER, + query: PAGE_QUERY_PATTERN_MATCHER, + hash: ANY_HASH_PATTERN_MATCHER, + }, + ]) expect(matcher.resolve('/users/24?page=100#bar')).toMatchObject({ params: { id: 24, page: 100, hash: 'bar' }, @@ -179,8 +232,9 @@ describe('Matcher', () => { describe('relative locations as strings', () => { it('resolves a simple relative location', () => { - const matcher = createCompiledMatcher() - matcher.addRoute({ path: ANY_PATH_PATTERN_MATCHER }) + const matcher = createCompiledMatcher([ + { path: ANY_PATH_PATTERN_MATCHER }, + ]) expect( matcher.resolve('foo', matcher.resolve('/nested/')) @@ -211,8 +265,7 @@ describe('Matcher', () => { describe('absolute locations as objects', () => { it('resolves an object location', () => { - const matcher = createCompiledMatcher() - matcher.addRoute(EMPTY_PATH_ROUTE) + const matcher = createCompiledMatcher([EMPTY_PATH_ROUTE]) expect(matcher.resolve({ path: '/' })).toMatchObject({ fullPath: '/', path: '/', @@ -225,11 +278,12 @@ describe('Matcher', () => { describe('named locations', () => { it('resolves named locations with no params', () => { - const matcher = createCompiledMatcher() - matcher.addRoute({ - name: 'home', - path: EMPTY_PATH_PATTERN_MATCHER, - }) + const matcher = createCompiledMatcher([ + { + name: 'home', + path: EMPTY_PATH_PATTERN_MATCHER, + }, + ]) expect(matcher.resolve({ name: 'home', params: {} })).toMatchObject({ name: 'home', diff --git a/packages/router/src/new-route-resolver/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts index f9fa1c6f8..cabb296ef 100644 --- a/packages/router/src/new-route-resolver/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -11,7 +11,7 @@ import type { MatcherPatternQuery, } from './matcher-pattern' import { warn } from '../warning' -import { encodeQueryValue as _encodeQueryValue } from '../encoding' +import { encodeQueryValue as _encodeQueryValue, encodeParam } from '../encoding' import { parseURL, stringifyURL } from '../location' import type { MatcherLocationAsNamed, @@ -102,6 +102,17 @@ type MatcherResolveArgs = currentLocation: NEW_LocationResolved ] +/** + * Allowed location objects to be passed to {@link RouteResolver['resolve']} + */ +export type MatcherLocationRaw = + | `/${string}` + | string + | MatcherLocationAsNamed + | MatcherLocationAsPathAbsolute + | MatcherLocationAsPathRelative + | MatcherLocationAsRelative + /** * Matcher capable of adding and removing routes at runtime. */ @@ -230,6 +241,28 @@ export interface MatcherRecordRaw { children?: MatcherRecordRaw[] } +/** + * Tagged template helper to encode params into a path. Doesn't work with null + */ +export function pathEncoded( + parts: TemplateStringsArray, + ...params: Array +): string { + return parts.reduce((result, part, i) => { + return ( + result + + part + + (Array.isArray(params[i]) + ? params[i].map(encodeParam).join('/') + : encodeParam(params[i])) + ) + }) +} + +// pathEncoded`/users/${1}` +// TODO: +// pathEncoded`/users/${null}/end` + // const a: RouteRecordRaw = {} as any /** @@ -245,10 +278,9 @@ function buildMatched(record: MatcherPattern): MatcherPattern[] { return matched } -export function createCompiledMatcher(): RouteResolver< - MatcherRecordRaw, - MatcherPattern -> { +export function createCompiledMatcher( + records: MatcherRecordRaw[] = [] +): RouteResolver { // TODO: we also need an array that has the correct order const matchers = new Map() @@ -386,6 +418,10 @@ export function createCompiledMatcher(): RouteResolver< return normalizedRecord } + for (const record of records) { + addRoute(record) + } + function removeRoute(matcher: MatcherPattern) { matchers.delete(matcher.name) // TODO: delete children and aliases diff --git a/packages/router/src/new-route-resolver/matchers/test-utils.ts b/packages/router/src/new-route-resolver/matchers/test-utils.ts new file mode 100644 index 000000000..f40ce00a5 --- /dev/null +++ b/packages/router/src/new-route-resolver/matchers/test-utils.ts @@ -0,0 +1,76 @@ +import { EmptyParams } from '../matcher-location' +import { + MatcherPatternPath, + MatcherPatternQuery, + MatcherPatternParams_Base, + MatcherPattern, +} from '../matcher-pattern' +import { miss } from './errors' + +export const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{ + pathMatch: string +}> = { + match(path) { + return { pathMatch: path } + }, + build({ pathMatch }) { + return pathMatch + }, +} + +export const EMPTY_PATH_PATTERN_MATCHER: MatcherPatternPath = { + match: path => { + if (path !== '/') { + throw miss() + } + return {} + }, + build: () => '/', +} + +export const USER_ID_PATH_PATTERN_MATCHER: MatcherPatternPath<{ id: number }> = + { + match(value) { + const match = value.match(/^\/users\/(\d+)$/) + if (!match?.[1]) { + throw miss() + } + const id = Number(match[1]) + if (Number.isNaN(id)) { + throw miss() + } + return { id } + }, + build({ id }) { + return `/users/${id}` + }, + } + +export const PAGE_QUERY_PATTERN_MATCHER: MatcherPatternQuery<{ page: number }> = + { + match: query => { + const page = Number(query.page) + return { + page: Number.isNaN(page) ? 1 : page, + } + }, + build: params => ({ page: String(params.page) }), + } satisfies MatcherPatternQuery<{ page: number }> + +export const ANY_HASH_PATTERN_MATCHER: MatcherPatternParams_Base< + string, + { hash: string | null } +> = { + match: hash => ({ hash: hash ? hash.slice(1) : null }), + build: ({ hash }) => (hash ? `#${hash}` : ''), +} + +export const EMPTY_PATH_ROUTE = { + name: 'no params', + path: EMPTY_PATH_PATTERN_MATCHER, +} satisfies MatcherPattern + +export const USER_ID_ROUTE = { + name: 'user-id', + path: USER_ID_PATH_PATTERN_MATCHER, +} satisfies MatcherPattern diff --git a/packages/router/src/types/utils.ts b/packages/router/src/types/utils.ts index e7d163184..2d443f69e 100644 --- a/packages/router/src/types/utils.ts +++ b/packages/router/src/types/utils.ts @@ -6,6 +6,16 @@ export type _LiteralUnion = | LiteralType | (BaseType & Record) +export type IsNull = + // avoid distributive conditional types + [T] extends [null] ? true : false + +export type IsUnknown = unknown extends T // `T` can be `unknown` or `any` + ? IsNull extends false // `any` can be `null`, but `unknown` can't be + ? true + : false + : false + /** * Maybe a promise maybe not * @internal