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
* @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, '[')
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
// 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)
)
}
}
-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'] {
--- /dev/null
+export { createCompiledMatcher } from './matcher'
--- /dev/null
+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
+}
--- /dev/null
+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¬Used¬Used=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.')
+ }
+}
--- /dev/null
+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: {},
+ })
+ })
+ })
+})
--- /dev/null
+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)
+ })
+})
--- /dev/null
+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,
+ }
+}
RouteParamsGeneric,
RouteComponent,
RouteParamsRawGeneric,
- RouteParamValueRaw,
RawRouteComponent,
} from '../types'
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