+++ /dev/null
-import type {
- MatcherName,
- MatcherPathParams,
- MatcherQueryParams,
- MatcherQueryParamsValue,
-} from './matcher'
-import type { MatcherParamsFormatted } from './matcher-location'
-
-/**
- * Allows to match, extract, parse and build a path. Tailored to iterate through route records and check if a location
- * matches. When it cannot match, it returns `null` instead of throwing to not force a try/catch block around each
- * iteration in for loops. Not meant to handle encoding/decoding. It expects different parts of the URL to be either
- * encoded or decoded depending on the method.
- */
-export interface MatcherPattern {
- /**
- * Name of the matcher. Unique across all matchers.
- */
- name: MatcherName
-
- // TODO: add route record to be able to build the matched
-
- /**
- * Extracts from an unencoded, parsed params object the ones belonging to the path, query, and hash in their
- * serialized format but still unencoded. e.g. `{ id: 2 }` -> `{ id: '2' }`. If any params are missing, return `null`.
- *
- * @param params - Params to extract from. If any params are missing, throws
- */
- matchParams(
- params: MatcherParamsFormatted
- ):
- | readonly [
- pathParams: MatcherPathParams,
- queryParams: MatcherQueryParams,
- hashParam: string
- ]
- | null
-
- /**
- * Extracts the defined params from an encoded path, decoded query, and decoded 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 becauso no /foo
- * // /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?other=2#hello
- * pattern.parseLocation({ path: '/foo', query: {}, hash: '#hello' })
- * // null // the query param is missing
- * ```
- *
- * @param location - URL parts to extract from
- * @param location.path - encoded path
- * @param location.query - decoded query
- * @param location.hash - decoded hash
- */
- matchLocation(location: {
- path: string
- query: MatcherQueryParams
- hash: string
- }):
- | readonly [
- pathParams: MatcherPathParams,
- queryParams: MatcherQueryParams,
- hashParam: string
- ]
- | null
-
- /**
- * Takes encoded params object to form the `path`,
- *
- * @param pathParams - encoded path params
- */
- buildPath(pathParams: MatcherPathParams): string
-
- /**
- * Runs the decoded params through the parsing functions if any, allowing them to be in be of a type other than a
- * string.
- *
- * @param pathParams - decoded path params
- * @param queryParams - decoded query params
- * @param hashParam - decoded hash param
- */
- parseParams(
- pathParams: MatcherPathParams,
- queryParams: MatcherQueryParams,
- hashParam: string
- ): MatcherParamsFormatted | null
-}
-
-interface PatternParamOptions_Base<T = unknown> {
- get: (value: MatcherQueryParamsValue) => T
- set?: (value: T) => MatcherQueryParamsValue
- default?: T | (() => T)
-}
-
-export interface PatternPathParamOptions<T = unknown>
- extends PatternParamOptions_Base<T> {
- re: RegExp
- keys: string[]
-}
-
-export interface PatternQueryParamOptions<T = unknown>
- extends PatternParamOptions_Base<T> {
- // FIXME: can be removed? seems to be the same as above
- get: (value: MatcherQueryParamsValue) => T
- set?: (value: T) => MatcherQueryParamsValue
-}
-
-// TODO: allow more than strings
-export interface PatternHashParamOptions
- extends PatternParamOptions_Base<string> {}
-
-export interface MatcherPatternPath {
- buildPath(path: MatcherPathParams): string
- match(path: string): MatcherPathParams
- parse?(params: MatcherPathParams): MatcherParamsFormatted
- serialize?(params: MatcherParamsFormatted): MatcherPathParams
-}
-
-export interface MatcherPatternQuery {
- match(query: MatcherQueryParams): MatcherQueryParams
- parse(params: MatcherQueryParams): MatcherParamsFormatted
- serialize(params: MatcherParamsFormatted): MatcherQueryParams
-}
-
-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
- parse(hash: string): MatcherParamsFormatted
- serialize(params: MatcherParamsFormatted): string
-}
-
-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
- }) {
- // TODO: is this performant? bench compare to a check with `null`
- try {
- return [
- this.path.match(location.path),
- this.query?.match(location.query) ?? {},
- this.hash?.match(location.hash) ?? '',
- ] as const
- } catch {
- return null
- }
- }
-
- parseParams(
- path: MatcherPathParams,
- query: MatcherQueryParams,
- hash: string
- ): MatcherParamsFormatted {
- return {
- ...this.path.parse?.(path),
- ...this.query?.parse(query),
- ...this.hash?.parse(hash),
- }
- }
-
- buildPath(path: MatcherPathParams): string {
- return this.path.buildPath(path)
- }
-
- matchParams(
- params: MatcherParamsFormatted
- ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] {
- return [
- this.path.serialize?.(params) ?? {},
- this.query?.serialize(params) ?? {},
- this.hash?.serialize(params) ?? '',
- ]
- }
-}
import { describe, expect, it } from 'vitest'
-import { MatcherPatternImpl } from './matcher-pattern'
import { createCompiledMatcher, NO_MATCH_LOCATION } from './matcher'
import {
MatcherPatternParams_Base,
import { miss } from './matchers/errors'
import { EmptyParams } from './matcher-location'
-function createMatcherPattern(
- ...args: ConstructorParameters<typeof MatcherPatternImpl>
-) {
- return new MatcherPatternImpl(...args)
-}
-
const ANY_PATH_PATTERN_MATCHER: MatcherPatternPath<{ pathMatch: string }> = {
match(path) {
return { pathMatch: path }
MatcherPatternQuery,
} from './new-matcher-pattern'
import { warn } from '../warning'
-import {
- SLASH_RE,
- encodePath,
- encodeQueryValue as _encodeQueryValue,
-} from '../encoding'
+import { encodeQueryValue as _encodeQueryValue } from '../encoding'
import { parseURL, stringifyURL } from '../location'
import type {
MatcherLocationAsNamed,
MatcherLocationAsRelative,
MatcherParamsFormatted,
} from './matcher-location'
-import { RouteRecordRaw } from 'test-dts'
/**
* Allowed types for a matcher name.
(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
-}
+// 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 : _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 const NO_MATCH_LOCATION = {
name: __DEV__ ? Symbol('no-match') : Symbol(),
params: {},
+++ /dev/null
-import type { MatcherPathParams } from '../matcher'
-import { MatcherParamsFormatted } from '../matcher-location'
-import type {
- MatcherPatternPath,
- PatternPathParamOptions,
-} from '../matcher-pattern'
-
-export class PatterParamPath<T> implements MatcherPatternPath {
- options: Required<Omit<PatternPathParamOptions<T>, 'default'>> & {
- default: undefined | (() => T) | T
- }
-
- constructor(options: PatternPathParamOptions<T>) {
- this.options = {
- set: String,
- default: undefined,
- ...options,
- }
- }
-
- match(path: string): MatcherPathParams {
- const match = this.options.re.exec(path)?.groups ?? {}
- if (!match) {
- throw new Error(
- `Path "${path}" does not match the pattern "${String(
- this.options.re
- )}"}`
- )
- }
- const params: MatcherPathParams = {}
- for (let i = 0; i < this.options.keys.length; i++) {
- params[this.options.keys[i]] = match[i + 1] ?? null
- }
- return params
- }
-
- buildPath(path: MatcherPathParams): string {
- throw new Error('Method not implemented.')
- }
-
- parse(params: MatcherPathParams): MatcherParamsFormatted {
- throw new Error('Method not implemented.')
- }
-
- serialize(params: MatcherParamsFormatted): MatcherPathParams {
- throw new Error('Method not implemented.')
- }
-}
+++ /dev/null
-import { describe, expect, it } from 'vitest'
-import { MatcherPathStatic } from './path-static'
-
-describe('PathStaticMatcher', () => {
- it('matches', () => {
- expect(new MatcherPathStatic('/').match('/')).toEqual({})
- expect(() => new MatcherPathStatic('/').match('/no')).toThrowError()
- expect(new MatcherPathStatic('/ok/ok').match('/ok/ok')).toEqual({})
- expect(() => new MatcherPathStatic('/ok/ok').match('/ok/no')).toThrowError()
- })
-
- it('builds path', () => {
- expect(new MatcherPathStatic('/').buildPath()).toBe('/')
- expect(new MatcherPathStatic('/ok').buildPath()).toBe('/ok')
- expect(new MatcherPathStatic('/ok/ok').buildPath()).toEqual('/ok/ok')
- })
-})
+++ /dev/null
-import type { MatcherPatternPath } from '../matcher-pattern'
-import { miss } from './errors'
-
-export class MatcherPathStatic implements MatcherPatternPath {
- constructor(private path: string) {}
-
- match(path: string) {
- if (this.path === path) return {}
- throw miss()
- }
-
- buildPath() {
- return this.path
- }
-}
import { MatcherName, MatcherQueryParams } from './matcher'
import { EmptyParams, MatcherParamsFormatted } from './matcher-location'
-import { MatchMiss, miss } from './matchers/errors'
-
-export interface MatcherLocation {
- /**
- * Encoded path
- */
- path: string
-
- /**
- * Decoded query.
- */
- query: MatcherQueryParams
-
- /**
- * Decoded hash.
- */
- hash: string
-}
-
-export interface OLD_MatcherPattern<TParams = MatcherParamsFormatted> {
- /**
- * Name of the matcher. Unique across all matchers.
- */
- name: MatcherName
-
- match(location: MatcherLocation): TParams | null
-
- toLocation(params: TParams): MatcherLocation
-}
+import { miss } from './matchers/errors'
export interface MatcherPattern {
/**
TOut extends MatcherParamsFormatted = MatcherParamsFormatted
> {
match(value: TIn): TOut
-
build(params: TOut): TIn
- // get: (value: MatcherQueryParamsValue) => T
- // set?: (value: T) => MatcherQueryParamsValue
- // default?: T | (() => T)
}
export interface MatcherPatternPath<
- TParams extends MatcherParamsFormatted = MatcherParamsFormatted
+ 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
> extends MatcherPatternParams_Base<string, TParams> {}
export class MatcherPatternPathStatic
// example of a static matcher built at runtime
// new MatcherPatternPathStatic('/')
-// example of a generated matcher at build time
-const HomePathMatcher = {
- match: path => {
- if (path !== '/') {
- throw miss()
- }
- return {}
- },
- build: () => '/',
-} satisfies MatcherPatternPath<EmptyParams>
-
export interface MatcherPatternQuery<
TParams extends MatcherParamsFormatted = MatcherParamsFormatted
> extends MatcherPatternParams_Base<MatcherQueryParams, TParams> {}
-const PaginationQueryMatcher = {
- 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 interface MatcherPatternHash<
TParams extends MatcherParamsFormatted = MatcherParamsFormatted
> extends MatcherPatternParams_Base<string, TParams> {}
-
-const HeaderHashMatcher = {
- match: hash =>
- hash.startsWith('#')
- ? {
- header: hash.slice(1),
- }
- : {}, // null also works
- build: ({ header }) => (header ? `#${header}` : ''),
-} satisfies MatcherPatternHash<{ header?: string }>
-
-export class MatcherPatternImpl<
- PathParams extends MatcherParamsFormatted,
- QueryParams extends MatcherParamsFormatted = EmptyParams,
- HashParams extends MatcherParamsFormatted = EmptyParams
-> implements OLD_MatcherPattern<PathParams & QueryParams & HashParams>
-{
- parent: MatcherPatternImpl<MatcherParamsFormatted> | null = null
- children: MatcherPatternImpl<MatcherParamsFormatted>[] = []
-
- constructor(
- public name: MatcherName,
- private path: MatcherPatternPath<PathParams>,
- private query?: MatcherPatternQuery<QueryParams>,
- private hash?: MatcherPatternHash<HashParams>
- ) {}
-
- /**
- * Matches a parsed query against the matcher and all of the parents.
- * @param query - query to match
- * @returns matched
- * @throws {MatchMiss} if the query does not match
- */
- queryMatch<QParams extends QueryParams>(query: MatcherQueryParams): QParams {
- // const queryParams: QParams = {} as QParams
- const queryParams: QParams[] = []
- let current: MatcherPatternImpl<
- MatcherParamsFormatted,
- MatcherParamsFormatted,
- MatcherParamsFormatted
- > | null = this
-
- while (current) {
- queryParams.push(current.query?.match(query) as QParams)
- current = current.parent
- }
- // we give the later matchers precedence
- return Object.assign({}, ...queryParams.reverse())
- }
-
- queryBuild<QParams extends QueryParams>(params: QParams): MatcherQueryParams {
- const query: MatcherQueryParams = {}
- let current: MatcherPatternImpl<
- MatcherParamsFormatted,
- MatcherParamsFormatted,
- MatcherParamsFormatted
- > | null = this
- while (current) {
- Object.assign(query, current.query?.build(params))
- current = current.parent
- }
- return query
- }
-
- match<QParams extends QueryParams>(
- location: MatcherLocation
- ): (PathParams & QParams & HashParams) | null {
- try {
- const pathParams = this.path.match(location.path)
- const queryParams = this.queryMatch<QParams>(location.query)
- const hashParams = this.hash?.match(location.hash) ?? ({} as HashParams)
-
- return { ...pathParams, ...queryParams, ...hashParams }
- } catch (err) {}
-
- return null
- }
-
- toLocation(params: PathParams & QueryParams & HashParams): MatcherLocation {
- return {
- path: this.path.build(params),
- query: this.query?.build(params) ?? {},
- hash: this.hash?.build(params) ?? '',
- }
- }
-}
-
-// const matcher = new MatcherPatternImpl('name', HomePathMatcher, PaginationQueryMatcher, HeaderHashMatcher)
-// matcher.match({ path: '/', query: {}, hash: '' })!.page