]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat: new dynamic path matcher
authorEduardo San Martin Morote <posva13@gmail.com>
Mon, 16 Dec 2024 14:35:49 +0000 (15:35 +0100)
committerEduardo San Martin Morote <posva13@gmail.com>
Mon, 16 Dec 2024 14:35:49 +0000 (15:35 +0100)
packages/router/src/new-route-resolver/matcher-pattern.ts
packages/router/src/new-route-resolver/matcher-resolve.spec.ts [new file with mode: 0644]
packages/router/src/new-route-resolver/matcher.spec.ts
packages/router/src/new-route-resolver/matcher.ts
packages/router/src/new-route-resolver/matchers/test-utils.ts [new file with mode: 0644]
packages/router/src/types/utils.ts

index 25d7c22ec3ed6da8744b7d0c5e00df07b43828d8..ad582bb8d4f1b9052be29b9b70e966d742d0468e 100644 (file)
@@ -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<string, TParams> {}
 
 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<TIn>) => TOut
+  set?: (value: NoInfer<TOut>) => TIn
+}
+
+export type ParamParser_Generic =
+  | Param_GetSet<string, any>
+  | Param_GetSet<string[], any>
+// 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<TOut, TIn extends string | string[]>(parser: {
+  get?: (value: TIn) => TOut
+  set?: (value: TOut) => TIn
+}): Param_GetSet<TIn, TOut> {
+  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<P extends Record<string, ParamParser_Generic>> = {
+ *   [K in keyof P]: P[K] extends Param_GetSet<infer TIn, infer TOut>
+ *     ? 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<string, ParamParser_Generic>
+ * > implements MatcherPatternPath<ParamsFromParsers<ParamsParser>>
+ * {
+ *   private params: Record<string, Required<ParamParser_Generic>> = {}
+ *   constructor(
+ *     private re: RegExp,
+ *     params: ParamsParser,
+ *     public build: (params: ParamsFromParsers<ParamsParser>) => string
+ *     ) {}
+ * ```
+ * It ended up not working in one place or another. It could probably be fixed by
+ */
+
+export type ParamsFromParsers<P extends Record<string, ParamParser_Generic>> = {
+  [K in keyof P]: P[K] extends Param_GetSet<infer TIn, infer TOut>
+    ? 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<TParams>
+{
+  private params: Record<string, Required<ParamParser_Generic>> = {}
+  constructor(
+    private re: RegExp,
+    params: Record<keyof TParams, ParamParser_Generic>,
+    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<MatcherQueryParams, TParams> {}
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 (file)
index 0000000..b4799bb
--- /dev/null
@@ -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<PathParams>,
+
+    children: record.children?.map(childRecord =>
+      compileRouteRecord(childRecord, record)
+    ),
+  }
+}
+
+describe('RouterMatcher.resolve', () => {
+  mockWarn()
+  type Matcher = ReturnType<typeof createRouterMatcher>
+  type MatcherResolvedLocation = ReturnType<Matcher['resolve']>
+
+  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<MatcherResolvedLocation>,
+    fromLocation:
+      | NEW_LocationResolved
+      | Exclude<MatcherLocationRaw, string>
+      | `/${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<MatcherLocationNormalizedLoose>,
+    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` }],
+        }
+      )
+    })
+  })
+})
index c15561f53db0712d76fd1a76e894803d2006a788..f508fe113877fd6ce98e8fc03f8bf3e36e440e69 100644 (file)
@@ -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',
index f9fa1c6f83253c07cb41519437fdba56b93f1093..cabb296ef6af2be5a1bff6f06955834d7022b1f7 100644 (file)
@@ -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 | number | (string | number)[]>
+): 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<MatcherRecordRaw, MatcherPattern> {
   // TODO: we also need an array that has the correct order
   const matchers = new Map<MatcherName, MatcherPattern>()
 
@@ -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 (file)
index 0000000..f40ce00
--- /dev/null
@@ -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<EmptyParams> = {
+  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
index e7d163184072c83afe23ab4b0868027833a38671..2d443f69e58d849ecdaf1071057b499e1f34efd4 100644 (file)
@@ -6,6 +6,16 @@ export type _LiteralUnion<LiteralType, BaseType extends string = string> =
   | LiteralType
   | (BaseType & Record<never, never>)
 
+export type IsNull<T> =
+  // avoid distributive conditional types
+  [T] extends [null] ? true : false
+
+export type IsUnknown<T> = unknown extends T // `T` can be `unknown` or `any`
+  ? IsNull<T> extends false // `any` can be `null`, but `unknown` can't be
+    ? true
+    : false
+  : false
+
 /**
  * Maybe a promise maybe not
  * @internal