]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat: wip new matcher
authorEduardo San Martin Morote <posva13@gmail.com>
Tue, 25 Jun 2024 13:56:55 +0000 (15:56 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Wed, 4 Dec 2024 15:10:33 +0000 (16:10 +0100)
packages/router/src/encoding.ts
packages/router/src/matcher/index.ts
packages/router/src/new-matcher/index.ts [new file with mode: 0644]
packages/router/src/new-matcher/matcher-location.ts [new file with mode: 0644]
packages/router/src/new-matcher/matcher-pattern.ts [new file with mode: 0644]
packages/router/src/new-matcher/matcher.spec.ts [new file with mode: 0644]
packages/router/src/new-matcher/matcher.test-d.ts [new file with mode: 0644]
packages/router/src/new-matcher/matcher.ts [new file with mode: 0644]
packages/router/src/utils/index.ts

index 69b338a65eac6d2124c193e3aa7cf2724446e74b..74d304928fc8918069ffe5bccf36ca758ea06929 100644 (file)
@@ -22,7 +22,7 @@ import { warn } from './warning'
 
 const HASH_RE = /#/g // %23
 const AMPERSAND_RE = /&/g // %26
-const SLASH_RE = /\//g // %2F
+export const SLASH_RE = /\//g // %2F
 const EQUAL_RE = /=/g // %3D
 const IM_RE = /\?/g // %3F
 export const PLUS_RE = /\+/g // %2B
@@ -58,7 +58,7 @@ const ENC_SPACE_RE = /%20/g // }
  * @param text - string to encode
  * @returns encoded string
  */
-function commonEncode(text: string | number): string {
+export function commonEncode(text: string | number): string {
   return encodeURI('' + text)
     .replace(ENC_PIPE_RE, '|')
     .replace(ENC_BRACKET_OPEN_RE, '[')
index 9d787ddbc6065d01345d1b48fea36aa66447ac20..fe951f7ad179eb5f7ae3bb1a7eeaa672190a0259 100644 (file)
@@ -271,7 +271,7 @@ export function createRouterMatcher(
       name = matcher.record.name
       params = assign(
         // paramsFromLocation is a new object
-        paramsFromLocation(
+        pickParams(
           currentLocation.params,
           // only keep params that exist in the resolved location
           // only keep optional params coming from a parent record
@@ -285,7 +285,7 @@ export function createRouterMatcher(
         // discard any existing params in the current location that do not exist here
         // #1497 this ensures better active/exact matching
         location.params &&
-          paramsFromLocation(
+          pickParams(
             location.params,
             matcher.keys.map(k => k.name)
           )
@@ -365,7 +365,13 @@ export function createRouterMatcher(
   }
 }
 
-function paramsFromLocation(
+/**
+ * Picks an object param to contain only specified keys.
+ *
+ * @param params - params object to pick from
+ * @param keys - keys to pick
+ */
+function pickParams(
   params: MatcherLocation['params'],
   keys: string[]
 ): MatcherLocation['params'] {
diff --git a/packages/router/src/new-matcher/index.ts b/packages/router/src/new-matcher/index.ts
new file mode 100644 (file)
index 0000000..17910f6
--- /dev/null
@@ -0,0 +1 @@
+export { createCompiledMatcher } from './matcher'
diff --git a/packages/router/src/new-matcher/matcher-location.ts b/packages/router/src/new-matcher/matcher-location.ts
new file mode 100644 (file)
index 0000000..bb44326
--- /dev/null
@@ -0,0 +1,32 @@
+import type { LocationQueryRaw } from '../query'
+import type { MatcherName } from './matcher'
+
+// the matcher can serialize and deserialize params
+export type MatcherParamsFormatted = Record<string, unknown>
+
+export interface MatcherLocationAsName {
+  name: MatcherName
+  params: MatcherParamsFormatted
+  query?: LocationQueryRaw
+  hash?: string
+
+  path?: undefined
+}
+
+export interface MatcherLocationAsPath {
+  path: string
+  query?: LocationQueryRaw
+  hash?: string
+
+  name?: undefined
+  params?: undefined
+}
+
+export interface MatcherLocationAsRelative {
+  params?: MatcherParamsFormatted
+  query?: LocationQueryRaw
+  hash?: string
+
+  name?: undefined
+  path?: undefined
+}
diff --git a/packages/router/src/new-matcher/matcher-pattern.ts b/packages/router/src/new-matcher/matcher-pattern.ts
new file mode 100644 (file)
index 0000000..021b975
--- /dev/null
@@ -0,0 +1,144 @@
+import type {
+  MatcherName,
+  MatcherPathParams,
+  MatcherQueryParams,
+  MatcherQueryParamsValue,
+} from './matcher'
+import type { MatcherParamsFormatted } from './matcher-location'
+
+export interface MatcherPattern {
+  /**
+   * Name of the matcher. Unique across all matchers.
+   */
+  name: MatcherName
+
+  /**
+   * Extracts from a formatted, unencoded params object the ones belonging to the path, query, and hash. If any of them is missing, returns `null`. TODO: throw instead?
+   * @param params - Params to extract from.
+   */
+  unformatParams(
+    params: MatcherParamsFormatted
+  ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string | null]
+
+  /**
+   * Extracts the defined params from an encoded path, query, and hash parsed from a URL. Does not apply formatting or
+   * decoding. If the URL does not match the pattern, returns `null`.
+   *
+   * @example
+   * ```ts
+   * const pattern = createPattern('/foo', {
+   *   path: {}, // nothing is used from the path
+   *   query: { used: String }, // we require a `used` query param
+   * })
+   * // /?used=2
+   * pattern.parseLocation({ path: '/', query: { used: '' }, hash: '' }) // null
+   * // /foo?used=2&notUsed&notUsed=2#hello
+   * pattern.parseLocation({ path: '/foo', query: { used: '2', notUsed: [null, '2']}, hash: '#hello' })
+   * // { used: '2' } // we extract the required params
+   * // /foo?used=2#hello
+   * pattern.parseLocation({ path: '/foo', query: {}, hash: '#hello' })
+   * // null // the query param is missing
+   * ```
+   */
+  matchLocation(location: {
+    path: string
+    query: MatcherQueryParams
+    hash: string
+  }): [path: MatcherPathParams, query: MatcherQueryParams, hash: string | null]
+
+  /**
+   * Takes encoded params object to form the `path`,
+   * @param path - encoded path params
+   */
+  buildPath(path: MatcherPathParams): string
+
+  /**
+   * Runs the decoded params through the formatting functions if any.
+   * @param params - Params to format.
+   */
+  formatParams(
+    path: MatcherPathParams,
+    query: MatcherQueryParams,
+    hash: string | null
+  ): MatcherParamsFormatted
+}
+
+interface PatternParamOptions_Base<T = unknown> {
+  get: (value: MatcherQueryParamsValue) => T
+  set?: (value: T) => MatcherQueryParamsValue
+  default?: T | (() => T)
+}
+
+export interface PatternParamOptions extends PatternParamOptions_Base {}
+
+export interface PatternQueryParamOptions<T = unknown>
+  extends PatternParamOptions_Base<T> {
+  get: (value: MatcherQueryParamsValue) => T
+  set?: (value: T) => MatcherQueryParamsValue
+}
+
+// TODO: allow more than strings
+export interface PatternHashParamOptions
+  extends PatternParamOptions_Base<string> {}
+
+export interface MatcherPatternPath {
+  match(path: string): MatcherPathParams
+  format(params: MatcherPathParams): MatcherParamsFormatted
+}
+
+export interface MatcherPatternQuery {
+  match(query: MatcherQueryParams): MatcherQueryParams
+  format(params: MatcherQueryParams): MatcherParamsFormatted
+}
+
+export interface MatcherPatternHash {
+  /**
+   * Check if the hash matches a pattern and returns it, still encoded with its leading `#`.
+   * @param hash - encoded hash
+   */
+  match(hash: string): string
+  format(hash: string): MatcherParamsFormatted
+}
+
+export class MatcherPatternImpl implements MatcherPattern {
+  constructor(
+    public name: MatcherName,
+    private path: MatcherPatternPath,
+    private query?: MatcherPatternQuery,
+    private hash?: MatcherPatternHash
+  ) {}
+
+  matchLocation(location: {
+    path: string
+    query: MatcherQueryParams
+    hash: string
+  }): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] {
+    return [
+      this.path.match(location.path),
+      this.query?.match(location.query) ?? {},
+      this.hash?.match(location.hash) ?? '',
+    ]
+  }
+
+  formatParams(
+    path: MatcherPathParams,
+    query: MatcherQueryParams,
+    hash: string
+  ): MatcherParamsFormatted {
+    return {
+      ...this.path.format(path),
+      ...this.query?.format(query),
+      ...this.hash?.format(hash),
+    }
+  }
+
+  buildPath(path: MatcherPathParams): string {
+    return ''
+  }
+
+  unformatParams(
+    params: MatcherParamsFormatted
+  ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string | null] {
+    throw new Error('Method not implemented.')
+  }
+}
diff --git a/packages/router/src/new-matcher/matcher.spec.ts b/packages/router/src/new-matcher/matcher.spec.ts
new file mode 100644 (file)
index 0000000..2660abd
--- /dev/null
@@ -0,0 +1,105 @@
+import { describe, expect, it } from 'vitest'
+import { MatcherPatternImpl, MatcherPatternPath } from './matcher-pattern'
+import { createCompiledMatcher } from './matcher'
+
+function createMatcherPattern(
+  ...args: ConstructorParameters<typeof MatcherPatternImpl>
+) {
+  return new MatcherPatternImpl(...args)
+}
+
+const EMPTY_PATH_PATTERN_MATCHER = {
+  match: (path: string) => ({}),
+  format: (params: {}) => ({}),
+} satisfies MatcherPatternPath
+
+describe('Matcher', () => {
+  describe('resolve()', () => {
+    it('resolves string locations with no params', () => {
+      const matcher = createCompiledMatcher()
+      matcher.addRoute(
+        createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER)
+      )
+
+      expect(matcher.resolve('/foo?a=a&b=b#h')).toMatchObject({
+        path: '/foo',
+        params: {},
+        query: { a: 'a', b: 'b' },
+        hash: '#h',
+      })
+    })
+
+    it('resolves string locations with params', () => {
+      const matcher = createCompiledMatcher()
+      matcher.addRoute(
+        // /users/:id
+        createMatcherPattern(Symbol('foo'), {
+          match: (path: string) => {
+            const match = path.match(/^\/foo\/([^/]+?)$/)
+            if (!match) throw new Error('no match')
+            return { id: match[1] }
+          },
+          format: (params: { id: string }) => ({ id: Number(params.id) }),
+        })
+      )
+
+      expect(matcher.resolve('/foo/1')).toMatchObject({
+        path: '/foo/1',
+        params: { id: 1 },
+        query: {},
+        hash: '',
+      })
+      expect(matcher.resolve('/foo/54')).toMatchObject({
+        path: '/foo/54',
+        params: { id: 54 },
+        query: {},
+        hash: '',
+      })
+    })
+
+    it('resolve string locations with query', () => {
+      const matcher = createCompiledMatcher()
+      matcher.addRoute(
+        createMatcherPattern(Symbol('foo'), EMPTY_PATH_PATTERN_MATCHER, {
+          match: query => ({
+            id: Array.isArray(query.id) ? query.id[0] : query.id,
+          }),
+          format: (params: { id: string }) => ({ id: Number(params.id) }),
+        })
+      )
+
+      expect(matcher.resolve('/foo?id=100')).toMatchObject({
+        hash: '',
+        params: {
+          id: 100,
+        },
+        path: '/foo',
+        query: {
+          id: '100',
+        },
+      })
+    })
+
+    it('resolves string locations with hash', () => {
+      const matcher = createCompiledMatcher()
+      matcher.addRoute(
+        createMatcherPattern(
+          Symbol('foo'),
+          EMPTY_PATH_PATTERN_MATCHER,
+          undefined,
+          {
+            match: hash => hash,
+            format: hash => ({ a: hash.slice(1) }),
+          }
+        )
+      )
+
+      expect(matcher.resolve('/foo#bar')).toMatchObject({
+        hash: '#bar',
+        params: { a: 'bar' },
+        path: '/foo',
+        query: {},
+      })
+    })
+  })
+})
diff --git a/packages/router/src/new-matcher/matcher.test-d.ts b/packages/router/src/new-matcher/matcher.test-d.ts
new file mode 100644 (file)
index 0000000..fbf150e
--- /dev/null
@@ -0,0 +1,16 @@
+import { describe, it } from 'vitest'
+import { NEW_MatcherLocationResolved, createCompiledMatcher } from './matcher'
+
+describe('Matcher', () => {
+  it('resolves locations', () => {
+    const matcher = createCompiledMatcher()
+    matcher.resolve('/foo')
+    // @ts-expect-error: needs currentLocation
+    matcher.resolve('foo')
+    matcher.resolve('foo', {} as NEW_MatcherLocationResolved)
+    matcher.resolve({ name: 'foo', params: {} })
+    // @ts-expect-error: needs currentLocation
+    matcher.resolve({ params: { id: 1 } })
+    matcher.resolve({ params: { id: 1 } }, {} as NEW_MatcherLocationResolved)
+  })
+})
diff --git a/packages/router/src/new-matcher/matcher.ts b/packages/router/src/new-matcher/matcher.ts
new file mode 100644 (file)
index 0000000..bd48a12
--- /dev/null
@@ -0,0 +1,292 @@
+import { type LocationQuery, parseQuery, normalizeQuery } from '../query'
+import type { MatcherPattern } from './matcher-pattern'
+import { warn } from '../warning'
+import {
+  SLASH_RE,
+  encodePath,
+  encodeQueryValue as _encodeQueryValue,
+} from '../encoding'
+import { parseURL } from '../location'
+import type {
+  MatcherLocationAsName,
+  MatcherLocationAsRelative,
+  MatcherParamsFormatted,
+} from './matcher-location'
+
+export type MatcherName = string | symbol
+
+/**
+ * Matcher capable of resolving route locations.
+ */
+export interface NEW_Matcher_Resolve {
+  /**
+   * Resolves an absolute location (like `/path/to/somewhere`).
+   */
+  resolve(absoluteLocation: `/${string}`): NEW_MatcherLocationResolved
+
+  /**
+   * Resolves a string location relative to another location. A relative location can be `./same-folder`,
+   * `../parent-folder`, or even `same-folder`.
+   */
+  resolve(
+    relativeLocation: string,
+    currentLocation: NEW_MatcherLocationResolved
+  ): NEW_MatcherLocationResolved
+
+  /**
+   * Resolves a location by its name. Any required params or query must be passed in the `options` argument.
+   */
+  resolve(location: MatcherLocationAsName): NEW_MatcherLocationResolved
+
+  /**
+   * Resolves a location by its path. Any required query must be passed.
+   * @param location - The location to resolve.
+   */
+  // resolve(location: MatcherLocationAsPath): NEW_MatcherLocationResolved
+  // NOTE: in practice, this overload can cause bugs. It's better to use named locations
+
+  /**
+   * Resolves a location relative to another location. It reuses existing properties in the `currentLocation` like
+   * `params`, `query`, and `hash`.
+   */
+  resolve(
+    relativeLocation: MatcherLocationAsRelative,
+    currentLocation: NEW_MatcherLocationResolved
+  ): NEW_MatcherLocationResolved
+
+  addRoute(matcher: MatcherPattern, parent?: MatcherPattern): void
+  removeRoute(matcher: MatcherPattern): void
+  clearRoutes(): void
+}
+
+type MatcherResolveArgs =
+  | [absoluteLocation: `/${string}`]
+  | [relativeLocation: string, currentLocation: NEW_MatcherLocationResolved]
+  | [location: MatcherLocationAsName]
+  | [
+      relativeLocation: MatcherLocationAsRelative,
+      currentLocation: NEW_MatcherLocationResolved
+    ]
+
+/**
+ * Matcher capable of adding and removing routes at runtime.
+ */
+export interface NEW_Matcher_Dynamic {
+  addRoute(record: TODO, parent?: TODO): () => void
+
+  removeRoute(record: TODO): void
+  removeRoute(name: MatcherName): void
+
+  clearRoutes(): void
+}
+
+type TODO = any
+
+export interface NEW_MatcherLocationResolved {
+  name: MatcherName
+  path: string
+  // TODO: generics?
+  params: MatcherParamsFormatted
+  query: LocationQuery
+  hash: string
+
+  matched: TODO[]
+}
+
+export type MatcherPathParamsValue = string | null | string[]
+/**
+ * Params in a string format so they can be encoded/decoded and put into a URL.
+ */
+export type MatcherPathParams = Record<string, MatcherPathParamsValue>
+
+export type MatcherQueryParamsValue = string | null | Array<string | null>
+export type MatcherQueryParams = Record<string, MatcherQueryParamsValue>
+
+export function applyToParams<R>(
+  fn: (v: string | number | null | undefined) => R,
+  params: MatcherPathParams | LocationQuery | undefined
+): Record<string, R | R[]> {
+  const newParams: Record<string, R | R[]> = {}
+
+  for (const key in params) {
+    const value = params[key]
+    newParams[key] = Array.isArray(value) ? value.map(fn) : fn(value)
+  }
+
+  return newParams
+}
+
+/**
+ * Decode text using `decodeURIComponent`. Returns the original text if it
+ * fails.
+ *
+ * @param text - string to decode
+ * @returns decoded string
+ */
+export function decode(text: string | number): string
+export function decode(text: null | undefined): null
+export function decode(text: string | number | null | undefined): string | null
+export function decode(
+  text: string | number | null | undefined
+): string | null {
+  if (text == null) return null
+  try {
+    return decodeURIComponent('' + text)
+  } catch (err) {
+    __DEV__ && warn(`Error decoding "${text}". Using original value`)
+  }
+  return '' + text
+}
+
+interface FnStableNull {
+  (value: null | undefined): null
+  (value: string | number): string
+  // needed for the general case and must be last
+  (value: string | number | null | undefined): string | null
+}
+
+function encodeParam(text: null | undefined, encodeSlash?: boolean): null
+function encodeParam(text: string | number, encodeSlash?: boolean): string
+function encodeParam(
+  text: string | number | null | undefined,
+  encodeSlash?: boolean
+): string | null
+function encodeParam(
+  text: string | number | null | undefined,
+  encodeSlash = true
+): string | null {
+  if (text == null) return null
+  text = encodePath(text)
+  return encodeSlash ? text.replace(SLASH_RE, '%2F') : text
+}
+
+// @ts-expect-error: overload are not correctly identified
+const encodeQueryValue: FnStableNull =
+  // for ts
+  value => (value == null ? null : _encodeQueryValue(value))
+
+// // @ts-expect-error: overload are not correctly identified
+// const encodeQueryKey: FnStableNull =
+//   // for ts
+//   value => (value == null ? null : _encodeQueryKey(value))
+
+function transformObject<T>(
+  fnKey: (value: string | number) => string,
+  fnValue: FnStableNull,
+  query: T
+): T {
+  const encoded: any = {}
+
+  for (const key in query) {
+    const value = query[key]
+    encoded[fnKey(key)] = Array.isArray(value)
+      ? value.map(fnValue)
+      : fnValue(value as string | number | null | undefined)
+  }
+
+  return encoded
+}
+
+export function createCompiledMatcher(): NEW_Matcher_Resolve {
+  const matchers = new Map<MatcherName, MatcherPattern>()
+
+  // TODO: allow custom encode/decode functions
+  // const encodeParams = applyToParams.bind(null, encodeParam)
+  // const decodeParams = transformObject.bind(null, String, decode)
+  // const encodeQuery = transformObject.bind(
+  //   null,
+  //   _encodeQueryKey,
+  //   encodeQueryValue
+  // )
+  // const decodeQuery = transformObject.bind(null, decode, decode)
+
+  function resolve(...args: MatcherResolveArgs): NEW_MatcherLocationResolved {
+    const [location, currentLocation] = args
+    if (typeof location === 'string') {
+      // string location, e.g. '/foo', '../bar', 'baz'
+      const url = parseURL(parseQuery, location, currentLocation?.path)
+
+      let matcher: MatcherPattern | undefined
+      let parsedParams: MatcherParamsFormatted | null | undefined
+
+      for (matcher of matchers.values()) {
+        const params = matcher.matchLocation(url)
+        if (params) {
+          parsedParams = matcher.formatParams(
+            transformObject(String, decode, params[0]),
+            transformObject(decode, decode, params[1]),
+            decode(params[2])
+          )
+          if (parsedParams) break
+        }
+      }
+      if (!parsedParams || !matcher) {
+        throw new Error(`No matcher found for location "${location}"`)
+      }
+      // TODO: build fullPath
+      return {
+        name: matcher.name,
+        path: url.path,
+        params: parsedParams,
+        query: transformObject(decode, decode, url.query),
+        hash: decode(url.hash),
+        matched: [],
+      }
+    } else {
+      // relative location or by name
+      const name = location.name ?? currentLocation!.name
+      const matcher = matchers.get(name)
+      if (!matcher) {
+        throw new Error(`Matcher "${String(location.name)}" not found`)
+      }
+
+      // unencoded params in a formatted form that the user came up with
+      const params = location.params ?? currentLocation!.params
+      const mixedUnencodedParams = matcher.unformatParams(params)
+
+      // TODO: they could just throw?
+      if (!mixedUnencodedParams) {
+        throw new Error(`Missing params for matcher "${String(name)}"`)
+      }
+
+      const path = matcher.buildPath(
+        // encode the values before building the path
+        transformObject(String, encodeParam, mixedUnencodedParams[0])
+      )
+
+      return {
+        name,
+        path,
+        params,
+        hash: mixedUnencodedParams[2] ?? location.hash ?? '',
+        // TODO: should pick query from the params but also from the location and merge them
+        query: {
+          ...normalizeQuery(location.query),
+          // ...matcher.extractQuery(mixedUnencodedParams[1])
+        },
+        matched: [],
+      }
+    }
+  }
+
+  function addRoute(matcher: MatcherPattern, parent?: MatcherPattern) {
+    matchers.set(matcher.name, matcher)
+  }
+
+  function removeRoute(matcher: MatcherPattern) {
+    matchers.delete(matcher.name)
+    // TODO: delete children and aliases
+  }
+
+  function clearRoutes() {
+    matchers.clear()
+  }
+
+  return {
+    resolve,
+
+    addRoute,
+    removeRoute,
+    clearRoutes,
+  }
+}
index b63f9dbb3d921e6704c1be8626a927ae23975732..a7c42f4cff551f320e4b2e2f35fc97fd1c72f153 100644 (file)
@@ -2,7 +2,6 @@ import {
   RouteParamsGeneric,
   RouteComponent,
   RouteParamsRawGeneric,
-  RouteParamValueRaw,
   RawRouteComponent,
 } from '../types'
 
@@ -45,9 +44,7 @@ export function applyToParams(
 
   for (const key in params) {
     const value = params[key]
-    newParams[key] = isArray(value)
-      ? value.map(fn)
-      : fn(value as Exclude<RouteParamValueRaw, any[]>)
+    newParams[key] = isArray(value) ? value.map(fn) : fn(value)
   }
 
   return newParams