From: Eduardo San Martin Morote Date: Tue, 25 Jun 2024 13:56:55 +0000 (+0200) Subject: feat: wip new matcher X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=2ee69154983e32bcf763a6cae9fb7a151ba93952;p=thirdparty%2Fvuejs%2Frouter.git feat: wip new matcher --- diff --git a/packages/router/src/encoding.ts b/packages/router/src/encoding.ts index 69b338a6..74d30492 100644 --- a/packages/router/src/encoding.ts +++ b/packages/router/src/encoding.ts @@ -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, '[') diff --git a/packages/router/src/matcher/index.ts b/packages/router/src/matcher/index.ts index 9d787ddb..fe951f7a 100644 --- a/packages/router/src/matcher/index.ts +++ b/packages/router/src/matcher/index.ts @@ -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 index 00000000..17910f62 --- /dev/null +++ b/packages/router/src/new-matcher/index.ts @@ -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 index 00000000..bb44326b --- /dev/null +++ b/packages/router/src/new-matcher/matcher-location.ts @@ -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 + +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 index 00000000..021b975c --- /dev/null +++ b/packages/router/src/new-matcher/matcher-pattern.ts @@ -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¬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 { + get: (value: MatcherQueryParamsValue) => T + set?: (value: T) => MatcherQueryParamsValue + default?: T | (() => T) +} + +export interface PatternParamOptions extends PatternParamOptions_Base {} + +export interface PatternQueryParamOptions + extends PatternParamOptions_Base { + get: (value: MatcherQueryParamsValue) => T + set?: (value: T) => MatcherQueryParamsValue +} + +// TODO: allow more than strings +export interface PatternHashParamOptions + extends PatternParamOptions_Base {} + +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 index 00000000..2660abdd --- /dev/null +++ b/packages/router/src/new-matcher/matcher.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { MatcherPatternImpl, MatcherPatternPath } from './matcher-pattern' +import { createCompiledMatcher } from './matcher' + +function createMatcherPattern( + ...args: ConstructorParameters +) { + 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 index 00000000..fbf150e2 --- /dev/null +++ b/packages/router/src/new-matcher/matcher.test-d.ts @@ -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 index 00000000..bd48a124 --- /dev/null +++ b/packages/router/src/new-matcher/matcher.ts @@ -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 + +export type MatcherQueryParamsValue = string | null | Array +export type MatcherQueryParams = Record + +export function applyToParams( + fn: (v: string | number | null | undefined) => R, + params: MatcherPathParams | LocationQuery | undefined +): Record { + const newParams: Record = {} + + 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( + 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() + + // 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, + } +} diff --git a/packages/router/src/utils/index.ts b/packages/router/src/utils/index.ts index b63f9dbb..a7c42f4c 100644 --- a/packages/router/src/utils/index.ts +++ b/packages/router/src/utils/index.ts @@ -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) + newParams[key] = isArray(value) ? value.map(fn) : fn(value) } return newParams