} 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.
+ */
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 a formatted, unencoded params object the ones belonging to the path, query, and hash.
- * @param params - Params to extract from.
+ * 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
*/
- unformatParams(
+ matchParams(
params: MatcherParamsFormatted
- ): [path: MatcherPathParams, query: MatcherQueryParams, hash: string]
+ ):
+ | readonly [
+ pathParams: MatcherPathParams,
+ queryParams: MatcherQueryParams,
+ hashParam: string
+ ]
+ | null
/**
* Extracts the defined params from an encoded path, query, and hash parsed from a URL. Does not apply formatting or
path: string
query: MatcherQueryParams
hash: string
- }): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] | null
+ }):
+ | readonly [
+ pathParams: MatcherPathParams,
+ queryParams: MatcherQueryParams,
+ hashParam: string
+ ]
+ | null
/**
* Takes encoded params object to form the `path`,
- * @param path - encoded path params
+ *
+ * @param pathParams - encoded path params
*/
- buildPath(path: MatcherPathParams): string
+ buildPath(pathParams: MatcherPathParams): string
/**
- * Runs the decoded params through the formatting functions if any.
- * @param params - Params to format.
+ * 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
*/
- formatParams(
- path: MatcherPathParams,
- query: MatcherQueryParams,
- hash: string
- ): MatcherParamsFormatted
+ parseParams(
+ pathParams: MatcherPathParams,
+ queryParams: MatcherQueryParams,
+ hashParam: string
+ ): MatcherParamsFormatted | null
}
interface PatternParamOptions_Base<T = unknown> {
default?: T | (() => T)
}
-export interface PatternParamOptions extends PatternParamOptions_Base {}
+export interface PatternPathParamOptions<T = unknown>
+ extends PatternParamOptions_Base<T> {
+ re: RegExp
+ keys: string[]
+}
export interface PatternQueryParamOptions<T = unknown>
extends PatternParamOptions_Base<T> {
extends PatternParamOptions_Base<string> {}
export interface MatcherPatternPath {
- build(path: MatcherPathParams): string
+ buildPath(path: MatcherPathParams): string
match(path: string): MatcherPathParams
- format(params: MatcherPathParams): MatcherParamsFormatted
- unformat(params: MatcherParamsFormatted): MatcherPathParams
+ parse?(params: MatcherPathParams): MatcherParamsFormatted
+ serialize?(params: MatcherParamsFormatted): MatcherPathParams
}
export interface MatcherPatternQuery {
match(query: MatcherQueryParams): MatcherQueryParams
- format(params: MatcherQueryParams): MatcherParamsFormatted
- unformat(params: MatcherParamsFormatted): MatcherQueryParams
+ parse(params: MatcherQueryParams): MatcherParamsFormatted
+ serialize(params: MatcherParamsFormatted): MatcherQueryParams
}
export interface MatcherPatternHash {
* @param hash - encoded hash
*/
match(hash: string): string
- format(hash: string): MatcherParamsFormatted
- unformat(params: MatcherParamsFormatted): string
+ parse(hash: string): MatcherParamsFormatted
+ serialize(params: MatcherParamsFormatted): string
}
export class MatcherPatternImpl implements MatcherPattern {
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) ?? '',
- ]
+ }) {
+ // TODO: is this performant? 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
+ }
}
- formatParams(
+ parseParams(
path: MatcherPathParams,
query: MatcherQueryParams,
hash: string
): MatcherParamsFormatted {
return {
- ...this.path.format(path),
- ...this.query?.format(query),
- ...this.hash?.format(hash),
+ ...this.path.parse?.(path),
+ ...this.query?.parse(query),
+ ...this.hash?.parse(hash),
}
}
buildPath(path: MatcherPathParams): string {
- return this.path.build(path)
+ return this.path.buildPath(path)
}
- unformatParams(
+ matchParams(
params: MatcherParamsFormatted
): [path: MatcherPathParams, query: MatcherQueryParams, hash: string] {
return [
- this.path.unformat(params),
- this.query?.unformat(params) ?? {},
- this.hash?.unformat(params) ?? '',
+ this.path.serialize?.(params) ?? {},
+ this.query?.serialize(params) ?? {},
+ this.hash?.serialize(params) ?? '',
]
}
}
const EMPTY_PATH_PATTERN_MATCHER = {
match: (path: string) => ({}),
- format: (params: {}) => ({}),
- unformat: (params: {}) => ({}),
- build: () => '/',
+ parse: (params: {}) => ({}),
+ serialize: (params: {}) => ({}),
+ buildPath: () => '/',
} satisfies MatcherPatternPath
describe('Matcher', () => {
if (!match) throw new Error('no match')
return { id: match[1] }
},
- format: (params: { id: string }) => ({ id: Number(params.id) }),
- unformat: (params: { id: number }) => ({ id: String(params.id) }),
- build: params => `/foo/${params.id}`,
+ parse: (params: { id: string }) => ({ id: Number(params.id) }),
+ serialize: (params: { id: number }) => ({ id: String(params.id) }),
+ buildPath: params => `/foo/${params.id}`,
})
)
match: query => ({
id: Array.isArray(query.id) ? query.id[0] : query.id,
}),
- format: (params: { id: string }) => ({ id: Number(params.id) }),
- unformat: (params: { id: number }) => ({ id: String(params.id) }),
+ parse: (params: { id: string }) => ({ id: Number(params.id) }),
+ serialize: (params: { id: number }) => ({ id: String(params.id) }),
})
)
undefined,
{
match: hash => hash,
- format: hash => ({ a: hash.slice(1) }),
- unformat: ({ a }) => '#a',
+ parse: hash => ({ a: hash.slice(1) }),
+ serialize: ({ a }) => '#a',
}
)
)
createMatcherPattern(
Symbol('foo'),
{
- build: params => `/foo/${params.id}`,
+ buildPath: params => `/foo/${params.id}`,
match: path => {
const match = path.match(/^\/foo\/([^/]+?)$/)
if (!match) throw new Error('no match')
return { id: match[1] }
},
- format: params => ({ id: Number(params.id) }),
- unformat: params => ({ id: String(params.id) }),
+ parse: params => ({ id: Number(params.id) }),
+ serialize: params => ({ id: String(params.id) }),
},
{
match: query => ({
id: Array.isArray(query.id) ? query.id[0] : query.id,
}),
- format: params => ({ q: Number(params.id) }),
- unformat: params => ({ id: String(params.q) }),
+ parse: params => ({ q: Number(params.id) }),
+ serialize: params => ({ id: String(params.q) }),
},
{
match: hash => hash,
- format: hash => ({ a: hash.slice(1) }),
- unformat: ({ a }) => '#a',
+ parse: hash => ({ a: hash.slice(1) }),
+ serialize: ({ a }) => '#a',
}
)
)
import { describe, it } from 'vitest'
-import { NEW_MatcherLocationResolved, createCompiledMatcher } from './matcher'
+import { NEW_LocationResolved, createCompiledMatcher } from './matcher'
describe('Matcher', () => {
it('resolves locations', () => {
matcher.resolve('/foo')
// @ts-expect-error: needs currentLocation
matcher.resolve('foo')
- matcher.resolve('foo', {} as NEW_MatcherLocationResolved)
+ matcher.resolve('foo', {} as NEW_LocationResolved)
matcher.resolve({ name: 'foo', params: {} })
// @ts-expect-error: needs currentLocation
matcher.resolve({ params: { id: 1 } })
- matcher.resolve({ params: { id: 1 } }, {} as NEW_MatcherLocationResolved)
+ matcher.resolve({ params: { id: 1 } }, {} as NEW_LocationResolved)
})
})
export type MatcherName = string | symbol
/**
- * Matcher capable of resolving route locations.
+ * Manage and resolve routes. Also handles the encoding, decoding, parsing and serialization of params, query, and hash.
*/
-export interface NEW_Matcher_Resolve {
+export interface RouteResolver {
/**
* Resolves an absolute location (like `/path/to/somewhere`).
*/
- resolve(absoluteLocation: `/${string}`): NEW_MatcherLocationResolved
+ resolve(absoluteLocation: `/${string}`): NEW_LocationResolved
/**
* Resolves a string location relative to another location. A relative location can be `./same-folder`,
*/
resolve(
relativeLocation: string,
- currentLocation: NEW_MatcherLocationResolved
- ): NEW_MatcherLocationResolved
+ currentLocation: NEW_LocationResolved
+ ): NEW_LocationResolved
/**
* Resolves a location by its name. Any required params or query must be passed in the `options` argument.
*/
- resolve(location: MatcherLocationAsName): NEW_MatcherLocationResolved
+ resolve(location: MatcherLocationAsName): NEW_LocationResolved
/**
* Resolves a location by its path. Any required query must be passed.
*/
resolve(
relativeLocation: MatcherLocationAsRelative,
- currentLocation: NEW_MatcherLocationResolved
- ): NEW_MatcherLocationResolved
+ currentLocation: NEW_LocationResolved
+ ): NEW_LocationResolved
addRoute(matcher: MatcherPattern, parent?: MatcherPattern): void
removeRoute(matcher: MatcherPattern): void
type MatcherResolveArgs =
| [absoluteLocation: `/${string}`]
- | [relativeLocation: string, currentLocation: NEW_MatcherLocationResolved]
+ | [relativeLocation: string, currentLocation: NEW_LocationResolved]
| [location: MatcherLocationAsName]
| [
relativeLocation: MatcherLocationAsRelative,
- currentLocation: NEW_MatcherLocationResolved
+ currentLocation: NEW_LocationResolved
]
/**
type TODO = any
-export interface NEW_MatcherLocationResolved {
+export interface NEW_LocationResolved {
name: MatcherName
fullPath: string
path: string
name: Symbol('no-match'),
params: {},
matched: [],
-} satisfies Omit<
- NEW_MatcherLocationResolved,
- 'path' | 'hash' | 'query' | 'fullPath'
->
+} satisfies Omit<NEW_LocationResolved, 'path' | 'hash' | 'query' | 'fullPath'>
-export function createCompiledMatcher(): NEW_Matcher_Resolve {
+export function createCompiledMatcher(): RouteResolver {
const matchers = new Map<MatcherName, MatcherPattern>()
// TODO: allow custom encode/decode functions
// )
// const decodeQuery = transformObject.bind(null, decode, decode)
- function resolve(...args: MatcherResolveArgs): NEW_MatcherLocationResolved {
+ function resolve(...args: MatcherResolveArgs): NEW_LocationResolved {
const [location, currentLocation] = args
if (typeof location === 'string') {
// string location, e.g. '/foo', '../bar', 'baz'
for (matcher of matchers.values()) {
const params = matcher.matchLocation(url)
if (params) {
- parsedParams = matcher.formatParams(
+ parsedParams = matcher.parseParams(
transformObject(String, decode, params[0]),
// already decoded
params[1],
// unencoded params in a formatted form that the user came up with
const params = location.params ?? currentLocation!.params
- const mixedUnencodedParams = matcher.unformatParams(params)
+ const mixedUnencodedParams = matcher.matchParams(params)
+
+ if (!mixedUnencodedParams) {
+ throw new Error(
+ `Invalid params for matcher "${String(name)}":\n${JSON.stringify(
+ params,
+ null,
+ 2
+ )}`
+ )
+ }
const path = matcher.buildPath(
// encode the values before building the path
--- /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 type { MatcherPatternPath } from '../matcher-pattern'
+
+export class PathMatcherStatic implements MatcherPatternPath {
+ constructor(private path: string) {}
+
+ match(path: string) {
+ if (this.path === path) return {}
+ throw new Error()
+ // return this.path === path ? {} : null
+ }
+
+ buildPath() {
+ return this.path
+ }
+}