]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat: MatcherPatternPathCustom
authorEduardo San Martin Morote <posva13@gmail.com>
Mon, 4 Aug 2025 10:00:00 +0000 (12:00 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Mon, 4 Aug 2025 10:00:00 +0000 (12:00 +0200)
packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts
packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts
packages/router/src/experimental/route-resolver/resolver-abstract.ts

index c3f8f021033f0fcd43cb5948ee130ec2e3ead43f..df6bf36db62c1463fe2e9a89b714f63d71ac4101 100644 (file)
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
 import {
   MatcherPatternPathStatic,
   MatcherPatternPathStar,
+  MatcherPatternPathCustom,
 } from './matcher-pattern'
+import { pathEncoded } from '../resolver-abstract'
+import { invalid } from './errors'
 
 describe('MatcherPatternPathStatic', () => {
   describe('match()', () => {
@@ -100,3 +103,126 @@ describe('MatcherPatternPathStar', () => {
     })
   })
 })
+
+describe('MatcherPatternPathCustom', () => {
+  it('single param', () => {
+    const pattern = new MatcherPatternPathCustom(
+      /^\/teams\/([^/]+?)\/b$/i,
+      {
+        // all defaults
+        teamId: {},
+      },
+      ({ teamId }) => {
+        if (typeof teamId !== 'string') {
+          throw invalid('teamId must be a string')
+        }
+        return pathEncoded`/teams/${teamId}/b`
+      }
+    )
+
+    expect(pattern.match('/teams/123/b')).toEqual({
+      teamId: '123',
+    })
+    expect(pattern.match('/teams/abc/b')).toEqual({
+      teamId: 'abc',
+    })
+    expect(() => pattern.match('/teams/123/c')).toThrow()
+    expect(() => pattern.match('/teams/123/b/c')).toThrow()
+    expect(() => pattern.match('/teams')).toThrow()
+    expect(() => pattern.match('/teams/')).toThrow()
+  })
+
+  it('decodes single param', () => {
+    const pattern = new MatcherPatternPathCustom(
+      /^\/teams\/([^/]+?)$/i,
+      {
+        teamId: {},
+      },
+      ({ teamId }) => {
+        if (typeof teamId !== 'string') {
+          throw invalid('teamId must be a string')
+        }
+        return pathEncoded`/teams/${teamId}`
+      }
+    )
+    expect(pattern.match('/teams/a%20b')).toEqual({ teamId: 'a b' })
+    expect(pattern.build({ teamId: 'a b' })).toBe('/teams/a%20b')
+  })
+
+  it('optional param', () => {
+    const pattern = new MatcherPatternPathCustom(
+      /^\/teams(?:\/([^/]+?))?\/b$/i,
+      {
+        teamId: { optional: true },
+      },
+      ({ teamId }) => {
+        if (teamId != null && typeof teamId !== 'string') {
+          throw invalid('teamId must be a string')
+        }
+        return teamId ? pathEncoded`/teams/${teamId}/b` : '/teams/b'
+      }
+    )
+
+    expect(pattern.match('/teams/b')).toEqual({ teamId: null })
+    expect(pattern.match('/teams/123/b')).toEqual({ teamId: '123' })
+    expect(() => pattern.match('/teams/123/c')).toThrow()
+    expect(() => pattern.match('/teams/123/b/c')).toThrow()
+    expect(pattern.build({ teamId: '123' })).toBe('/teams/123/b')
+    expect(pattern.build({ teamId: null })).toBe('/teams/b')
+  })
+
+  it('repeatable param', () => {
+    const pattern = new MatcherPatternPathCustom(
+      /^\/teams\/(.+?)\/b$/i,
+      {
+        teamId: { repeat: true },
+      },
+      ({ teamId }) => {
+        if (!Array.isArray(teamId)) {
+          throw invalid('teamId must be an array')
+        }
+        return '/teams/' + teamId.join('/') + '/b'
+      }
+    )
+
+    expect(pattern.match('/teams/123/b')).toEqual({ teamId: ['123'] })
+    expect(pattern.match('/teams/123/456/b')).toEqual({
+      teamId: ['123', '456'],
+    })
+    expect(() => pattern.match('/teams/123/c')).toThrow()
+    expect(() => pattern.match('/teams/123/b/c')).toThrow()
+    expect(pattern.build({ teamId: ['123'] })).toBe('/teams/123/b')
+    expect(pattern.build({ teamId: ['123', '456'] })).toBe('/teams/123/456/b')
+  })
+
+  it('repeatable optional param', () => {
+    const pattern = new MatcherPatternPathCustom(
+      /^\/teams(?:\/(.+?))?\/b$/i,
+      {
+        teamId: { repeat: true, optional: true },
+      },
+      ({ teamId }) => {
+        if (!Array.isArray(teamId)) {
+          throw invalid('teamId must be an array')
+        }
+        const joined = teamId.join('/')
+        return teamId
+          ? '/teams' + (joined ? '/' + joined : '') + '/b'
+          : '/teams/b'
+      }
+    )
+
+    expect(pattern.match('/teams/123/b')).toEqual({ teamId: ['123'] })
+    expect(pattern.match('/teams/123/456/b')).toEqual({
+      teamId: ['123', '456'],
+    })
+    expect(pattern.match('/teams/b')).toEqual({ teamId: [] })
+
+    expect(() => pattern.match('/teams/123/c')).toThrow()
+    expect(() => pattern.match('/teams/123/b/c')).toThrow()
+
+    expect(pattern.build({ teamId: ['123'] })).toBe('/teams/123/b')
+    expect(pattern.build({ teamId: ['123', '456'] })).toBe('/teams/123/456/b')
+    expect(pattern.build({ teamId: [] })).toBe('/teams/b')
+  })
+})
index f939aa571c3602ea92dc37e562bd75ddca698dbe..4f92a333a09833ff970d458314ae75650f3c5233 100644 (file)
@@ -119,8 +119,12 @@ export class MatcherPatternPathStar
 // new MatcherPatternPathStatic('/team')
 
 export interface Param_GetSet<
-  TIn extends string | string[] = string | string[],
-  TOut = TIn,
+  TIn extends string | string[] | null | undefined =
+    | string
+    | string[]
+    | null
+    | undefined,
+  TOut = string | string[] | null,
 > {
   get?: (value: NoInfer<TIn>) => TOut
   set?: (value: NoInfer<TOut>) => TIn
@@ -145,7 +149,13 @@ export function defineParamParser<TOut, TIn extends string | string[]>(parser: {
   return parser
 }
 
-const PATH_PARAM_DEFAULT_GET = (value: string | string[]) => value
+interface IdFn {
+  (v: undefined | null): null
+  (v: string): string
+  (v: string[]): string[]
+}
+
+const PATH_PARAM_DEFAULT_GET = (value => value ?? null) as IdFn
 const PATH_PARAM_DEFAULT_SET = (value: unknown) =>
   value && Array.isArray(value) ? value.map(String) : String(value)
 // TODO: `(value an null | undefined)` for types
@@ -183,6 +193,79 @@ export type ParamsFromParsers<P extends Record<string, ParamParser_Generic>> = {
     : never
 }
 
+/**
+ * TODO: it should accept a dict of param parsers for each param and if they are repeatable and optional
+ * The object order matters, they get matched in that order
+ */
+
+interface MatcherPatternPathDynamicParam<
+  TIn extends string | string[] | null | undefined =
+    | string
+    | string[]
+    | null
+    | undefined,
+  TOut = string | string[] | null,
+> {
+  repeat?: boolean
+  optional?: boolean
+  parser?: Param_GetSet<TIn, TOut>
+}
+
+export class MatcherPatternPathCustom implements MatcherPatternPath {
+  // private paramsKeys: string[]
+
+  constructor(
+    readonly re: RegExp,
+    readonly params: Record<string, MatcherPatternPathDynamicParam>,
+    readonly build: (params: MatcherParamsFormatted) => string
+    // A better version could be using all the parts to join them
+    // .e.g ['users', 0, 'profile', 1] -> /users/123/profile/456
+    // numbers are indexes of the params in the params object keys
+    // readonly pathParts: Array<string | number>
+  ) {
+    // this.paramsKeys = Object.keys(this.params)
+  }
+
+  match(path: string): MatcherParamsFormatted {
+    const match = path.match(this.re)
+    if (!match) {
+      throw miss()
+    }
+    const params = {} as MatcherParamsFormatted
+    let i = 1 // index in match array
+    for (const paramName in this.params) {
+      const currentParam = this.params[paramName]
+      // an optional group in the regexp will return undefined
+      const currentMatch = match[i++] as string | undefined
+      if (__DEV__ && !currentParam.optional && !currentMatch) {
+        warn(
+          `Unexpected undefined value for param "${paramName}". Regexp: ${String(this.re)}. path: "${path}". This is likely a bug.`
+        )
+        throw miss()
+      }
+
+      const value = currentParam.repeat
+        ? (currentMatch?.split('/') || []).map(
+            // using  just decode makes the type inference fail
+            v => decode(v)
+          )
+        : decode(currentMatch)
+
+      console.log(paramName, currentParam, value)
+
+      params[paramName] = (currentParam.parser?.get || (v => v ?? null))(value)
+    }
+
+    if (__DEV__ && i !== match.length) {
+      warn(
+        `Regexp matched ${match.length} params, but ${i} params are defined. Found when matching "${path}" against ${String(this.re)}`
+      )
+    }
+
+    return params
+  }
+}
+
 /**
  * Matcher for dynamic paths, e.g. `/team/:id/:name`.
  * Supports one, one or zero, one or more and zero or more params.
index d475590b06919c609ef873f0fd313ecb2b7e5dd0..abc70e918b1e100cd6997c5c233f749e6cbd5ec9 100644 (file)
@@ -228,7 +228,7 @@ export function pathEncoded(
         ? params[i].map(encodeParam).join('/')
         : encodeParam(params[i]))
     )
-  })
+  }, '')
 }
 export interface ResolverLocationAsNamed {
   name: RecordName