From: Eduardo San Martin Morote Date: Wed, 26 Jun 2024 13:52:51 +0000 (+0200) Subject: chore: static path matcher X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=6f2da87eb8e4b39df2710144733d06e24edd1da9;p=thirdparty%2Fvuejs%2Frouter.git chore: static path matcher --- diff --git a/packages/router/src/new-matcher/index.ts b/packages/router/src/new-route-resolver/index.ts similarity index 100% rename from packages/router/src/new-matcher/index.ts rename to packages/router/src/new-route-resolver/index.ts diff --git a/packages/router/src/new-matcher/matcher-location.ts b/packages/router/src/new-route-resolver/matcher-location.ts similarity index 100% rename from packages/router/src/new-matcher/matcher-location.ts rename to packages/router/src/new-route-resolver/matcher-location.ts diff --git a/packages/router/src/new-matcher/matcher-pattern.ts b/packages/router/src/new-route-resolver/matcher-pattern.ts similarity index 53% rename from packages/router/src/new-matcher/matcher-pattern.ts rename to packages/router/src/new-route-resolver/matcher-pattern.ts index f368a04f..a9f8f5e8 100644 --- a/packages/router/src/new-matcher/matcher-pattern.ts +++ b/packages/router/src/new-route-resolver/matcher-pattern.ts @@ -6,19 +6,34 @@ import type { } 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 @@ -44,23 +59,34 @@ export interface MatcherPattern { 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 { @@ -69,7 +95,11 @@ interface PatternParamOptions_Base { default?: T | (() => T) } -export interface PatternParamOptions extends PatternParamOptions_Base {} +export interface PatternPathParamOptions + extends PatternParamOptions_Base { + re: RegExp + keys: string[] +} export interface PatternQueryParamOptions extends PatternParamOptions_Base { @@ -82,16 +112,16 @@ export interface PatternHashParamOptions extends PatternParamOptions_Base {} 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 { @@ -100,8 +130,8 @@ 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 { @@ -116,37 +146,42 @@ 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) ?? '', ] } } diff --git a/packages/router/src/new-matcher/matcher.spec.ts b/packages/router/src/new-route-resolver/matcher.spec.ts similarity index 85% rename from packages/router/src/new-matcher/matcher.spec.ts rename to packages/router/src/new-route-resolver/matcher.spec.ts index 9c6ccb2f..52d8b208 100644 --- a/packages/router/src/new-matcher/matcher.spec.ts +++ b/packages/router/src/new-route-resolver/matcher.spec.ts @@ -10,9 +10,9 @@ function createMatcherPattern( const EMPTY_PATH_PATTERN_MATCHER = { match: (path: string) => ({}), - format: (params: {}) => ({}), - unformat: (params: {}) => ({}), - build: () => '/', + parse: (params: {}) => ({}), + serialize: (params: {}) => ({}), + buildPath: () => '/', } satisfies MatcherPatternPath describe('Matcher', () => { @@ -42,9 +42,9 @@ 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}`, }) ) @@ -69,8 +69,8 @@ describe('Matcher', () => { 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) }), }) ) @@ -94,8 +94,8 @@ describe('Matcher', () => { undefined, { match: hash => hash, - format: hash => ({ a: hash.slice(1) }), - unformat: ({ a }) => '#a', + parse: hash => ({ a: hash.slice(1) }), + serialize: ({ a }) => '#a', } ) ) @@ -138,26 +138,26 @@ describe('Matcher', () => { 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', } ) ) diff --git a/packages/router/src/new-matcher/matcher.test-d.ts b/packages/router/src/new-route-resolver/matcher.test-d.ts similarity index 64% rename from packages/router/src/new-matcher/matcher.test-d.ts rename to packages/router/src/new-route-resolver/matcher.test-d.ts index fbf150e2..412cb071 100644 --- a/packages/router/src/new-matcher/matcher.test-d.ts +++ b/packages/router/src/new-route-resolver/matcher.test-d.ts @@ -1,5 +1,5 @@ import { describe, it } from 'vitest' -import { NEW_MatcherLocationResolved, createCompiledMatcher } from './matcher' +import { NEW_LocationResolved, createCompiledMatcher } from './matcher' describe('Matcher', () => { it('resolves locations', () => { @@ -7,10 +7,10 @@ describe('Matcher', () => { 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) }) }) diff --git a/packages/router/src/new-matcher/matcher.ts b/packages/router/src/new-route-resolver/matcher.ts similarity index 87% rename from packages/router/src/new-matcher/matcher.ts rename to packages/router/src/new-route-resolver/matcher.ts index 9e39f44e..4aa742e9 100644 --- a/packages/router/src/new-matcher/matcher.ts +++ b/packages/router/src/new-route-resolver/matcher.ts @@ -21,13 +21,13 @@ import type { 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`, @@ -35,13 +35,13 @@ export interface NEW_Matcher_Resolve { */ 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. @@ -56,8 +56,8 @@ export interface NEW_Matcher_Resolve { */ resolve( relativeLocation: MatcherLocationAsRelative, - currentLocation: NEW_MatcherLocationResolved - ): NEW_MatcherLocationResolved + currentLocation: NEW_LocationResolved + ): NEW_LocationResolved addRoute(matcher: MatcherPattern, parent?: MatcherPattern): void removeRoute(matcher: MatcherPattern): void @@ -66,11 +66,11 @@ export interface NEW_Matcher_Resolve { type MatcherResolveArgs = | [absoluteLocation: `/${string}`] - | [relativeLocation: string, currentLocation: NEW_MatcherLocationResolved] + | [relativeLocation: string, currentLocation: NEW_LocationResolved] | [location: MatcherLocationAsName] | [ relativeLocation: MatcherLocationAsRelative, - currentLocation: NEW_MatcherLocationResolved + currentLocation: NEW_LocationResolved ] /** @@ -87,7 +87,7 @@ export interface NEW_Matcher_Dynamic { type TODO = any -export interface NEW_MatcherLocationResolved { +export interface NEW_LocationResolved { name: MatcherName fullPath: string path: string @@ -198,12 +198,9 @@ export const NO_MATCH_LOCATION = { name: Symbol('no-match'), params: {}, matched: [], -} satisfies Omit< - NEW_MatcherLocationResolved, - 'path' | 'hash' | 'query' | 'fullPath' -> +} satisfies Omit -export function createCompiledMatcher(): NEW_Matcher_Resolve { +export function createCompiledMatcher(): RouteResolver { const matchers = new Map() // TODO: allow custom encode/decode functions @@ -216,7 +213,7 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { // ) // 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' @@ -228,7 +225,7 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { 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], @@ -268,7 +265,17 @@ export function createCompiledMatcher(): NEW_Matcher_Resolve { // 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 diff --git a/packages/router/src/new-route-resolver/matchers/path-param.ts b/packages/router/src/new-route-resolver/matchers/path-param.ts new file mode 100644 index 00000000..e17e7806 --- /dev/null +++ b/packages/router/src/new-route-resolver/matchers/path-param.ts @@ -0,0 +1,48 @@ +import type { MatcherPathParams } from '../matcher' +import { MatcherParamsFormatted } from '../matcher-location' +import type { + MatcherPatternPath, + PatternPathParamOptions, +} from '../matcher-pattern' + +export class PatterParamPath implements MatcherPatternPath { + options: Required, 'default'>> & { + default: undefined | (() => T) | T + } + + constructor(options: PatternPathParamOptions) { + 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.') + } +} diff --git a/packages/router/src/new-route-resolver/matchers/path-static.ts b/packages/router/src/new-route-resolver/matchers/path-static.ts new file mode 100644 index 00000000..0d6ebd3f --- /dev/null +++ b/packages/router/src/new-route-resolver/matchers/path-static.ts @@ -0,0 +1,15 @@ +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 + } +}