From: Eduardo San Martin Morote Date: Sat, 26 Oct 2019 14:47:45 +0000 (+0100) Subject: refactor(matcher): split in multiple files X-Git-Tag: v4.0.0-alpha.0~180 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=ba21e9f1a2b7944ace1839b52f856a082db5d13d;p=thirdparty%2Fvuejs%2Frouter.git refactor(matcher): split in multiple files --- diff --git a/__tests__/matcher/ranking.spec.ts b/__tests__/matcher/ranking.spec.ts index 75126d38..8806ba29 100644 --- a/__tests__/matcher/ranking.spec.ts +++ b/__tests__/matcher/ranking.spec.ts @@ -1,4 +1,7 @@ -import { createRouteMatcher, RouteMatcher } from '../../src/matcher' +import { + createRouteMatcher, + RouteRecordMatcher, +} from '../../src/matcher/path-ranker' import { RegExpOptions } from 'path-to-regexp' import { RouteComponent } from '../../src/types' @@ -21,7 +24,7 @@ describe('createRouteMatcher', () => { }) const matchers: Array< - RouteMatcher & { _options: RegExpOptions } + RouteRecordMatcher & { _options: RegExpOptions } > = normalizedPaths .slice() // Because sorting order is conserved, allows to mismatch order on diff --git a/src/matcher/index.ts b/src/matcher/index.ts new file mode 100644 index 00000000..21257531 --- /dev/null +++ b/src/matcher/index.ts @@ -0,0 +1,220 @@ +import pathToRegexp from 'path-to-regexp' +import { + RouteRecord, + RouteParams, + MatcherLocation, + MatcherLocationNormalized, + MatcherLocationRedirect, + // TODO: add it to matched + // MatchedRouteRecord, +} from '../types' +import { NoRouteMatchError, InvalidRouteMatch } from '../errors' +import { createRouteMatcher, normalizeRouteRecord } from './path-ranker' +import { RouteRecordMatcher } from './types' + +export class RouterMatcher { + private matchers: RouteRecordMatcher[] = [] + + constructor(routes: RouteRecord[]) { + for (const route of routes) { + this.addRouteRecord(route) + } + } + + private addRouteRecord( + record: Readonly, + parent?: RouteRecordMatcher + ): void { + const options: pathToRegexp.RegExpOptions = { + // NOTE: should we make strict by default and redirect /users/ to /users + // so that it's the same from SEO perspective? + strict: false, + } + + // generate an array of records to correctly handle aliases + const normalizedRecords = [normalizeRouteRecord(record)] + if ('alias' in record && record.alias) { + const aliases = + typeof record.alias === 'string' ? [record.alias] : record.alias + for (const alias of aliases) { + const copyForAlias = normalizeRouteRecord(record) + copyForAlias.path = alias + normalizedRecords.push(copyForAlias) + } + } + + if (parent) { + // if the child isn't an absolute route + if (record.path[0] !== '/') { + let path = parent.record.path + // only add the / delimiter if the child path isn't empty + for (const normalizedRecord of normalizedRecords) { + if (normalizedRecord.path) path += '/' + path += record.path + normalizedRecord.path = path + } + } + } + + for (const normalizedRecord of normalizedRecords) { + // create the object before hand so it can be passed to children + const matcher = createRouteMatcher(normalizedRecord, parent, options) + + if ('children' in record && record.children) { + for (const childRecord of record.children) { + this.addRouteRecord(childRecord, matcher) + } + // TODO: the parent is special, we should match their children. They + // reference to the parent so we can render the parent + // + // matcher.score = -10 + } + + this.insertMatcher(matcher) + } + } + + private insertMatcher(matcher: RouteRecordMatcher) { + let i = 0 + while (i < this.matchers.length && matcher.score <= this.matchers[i].score) + i++ + this.matchers.splice(i, 0, matcher) + } + + /** + * Resolve a location without doing redirections so it can be used for anchors + */ + resolveAsPath() {} + + /** + * Transforms a MatcherLocation object into a normalized location + * @param location MatcherLocation to resolve to a url + * @param currentLocation MatcherLocationNormalized of the current location + */ + resolve( + location: Readonly, + currentLocation: Readonly + ): MatcherLocationNormalized | MatcherLocationRedirect { + let matcher: RouteRecordMatcher | void + let params: RouteParams = {} + let path: MatcherLocationNormalized['path'] + let name: MatcherLocationNormalized['name'] + + if ('name' in location && location.name) { + matcher = this.matchers.find(m => m.record.name === location.name) + + if (!matcher) throw new NoRouteMatchError(currentLocation, location) + + name = matcher.record.name + // TODO: merge params + params = location.params || currentLocation.params + // params are automatically encoded + // TODO: try catch to provide better error messages + path = matcher.resolve(params) + // TODO: check missing params + + if ('redirect' in matcher.record) { + const { redirect } = matcher.record + return { + redirect, + normalizedLocation: { + name, + path, + matched: [], + params, + meta: matcher.record.meta || {}, + }, + } + } + } else if ('path' in location) { + matcher = this.matchers.find(m => m.re.test(location.path)) + + // TODO: if no matcher, return the location with an empty matched array + // to allow non existent matches + // TODO: warning of unused params if provided + if (!matcher) throw new NoRouteMatchError(currentLocation, location) + + // no need to resolve the path with the matcher as it was provided + // this also allows the user to control the encoding + path = location.path + name = matcher.record.name + + // fill params + const result = matcher.re.exec(path) + + if (!result) { + // TODO: redo message: matching path against X + throw new Error(`Error parsing path "${location.path}"`) + } + + for (let i = 0; i < matcher.keys.length; i++) { + const key = matcher.keys[i] + let value: string = result[i + 1] + try { + value = decodeURIComponent(value) + } catch (err) { + if (err instanceof URIError) { + console.warn( + `[vue-router] failed decoding param "${key}" with value "${value}". When providing a string location or the "path" property, URL must be properly encoded (TODO: link). Falling back to unencoded value` + ) + } else { + throw err + } + } + if (!value) { + // TODO: handle optional params + throw new Error( + `Error parsing path "${location.path}" when looking for param "${key}"` + ) + } + params[key] = value + } + + if ('redirect' in matcher.record) { + const { redirect } = matcher.record + return { + redirect, + normalizedLocation: { + name, + path, + matched: [], + params, + meta: matcher.record.meta || {}, + }, + } + } + // location is a relative path + } else { + // match by name or path of current route + matcher = currentLocation.name + ? this.matchers.find(m => m.record.name === currentLocation.name) + : this.matchers.find(m => m.re.test(currentLocation.path)) + if (!matcher) throw new NoRouteMatchError(currentLocation, location) + name = matcher.record.name + params = location.params || currentLocation.params + path = matcher.resolve(params) + } + + // this should never happen because it will mean that the user ended up in a route + // that redirects but ended up not redirecting + if ('redirect' in matcher.record) throw new InvalidRouteMatch(location) + + const matched: MatcherLocationNormalized['matched'] = [matcher.record] + let parentMatcher: RouteRecordMatcher | void = matcher.parent + while (parentMatcher) { + // reversed order so parents are at the beginning + // TODO: should be doable by typing RouteRecordMatcher in a different way + if ('redirect' in parentMatcher.record) throw new Error('TODO') + matched.unshift(parentMatcher.record) + parentMatcher = parentMatcher.parent + } + + return { + name, + path, + params, + matched, + meta: matcher.record.meta || {}, + } + } +} diff --git a/src/matcher.ts b/src/matcher/path-ranker.ts similarity index 56% rename from src/matcher.ts rename to src/matcher/path-ranker.ts index 1ed75330..2c13f325 100644 --- a/src/matcher.ts +++ b/src/matcher/path-ranker.ts @@ -1,35 +1,14 @@ import pathToRegexp from 'path-to-regexp' import { RouteRecord, - RouteParams, - MatcherLocation, - MatcherLocationNormalized, - MatcherLocationRedirect, RouteRecordRedirect, RouteRecordMultipleViews, RouteRecordSingleView, Mutable, // TODO: add it to matched // MatchedRouteRecord, -} from './types/index' -import { NoRouteMatchError, InvalidRouteMatch } from './errors' - -// normalize component/components into components -type NormalizedRouteRecord = - | Omit - | Omit - -export interface RouteMatcher { - re: RegExp - resolve: (params?: RouteParams) => string - record: NormalizedRouteRecord - parent: RouteMatcher | void - // TODO: children so they can be removed - // children: RouteMatcher[] - // TODO: needs information like optional, repeatable - keys: string[] - score: number -} +} from '../types' +import { NormalizedRouteRecord, RouteRecordMatcher } from './types' function copyObject( a: T, @@ -66,7 +45,7 @@ const ROUTE_RECORD_MULTIPLE_VIEWS_KEYS: (keyof ( * @param record * @returns the normalized version */ -export function normalizeRecord( +export function normalizeRouteRecord( record: Readonly ): NormalizedRouteRecord { // TODO: could be refactored to improve typings @@ -112,9 +91,9 @@ const isDefaultPathRegExpRE = /^\[\^[^\]]+\]\+\?$/ export function createRouteMatcher( record: Readonly, - parent: RouteMatcher | void, + parent: RouteRecordMatcher | void, options: pathToRegexp.RegExpOptions -): RouteMatcher { +): RouteRecordMatcher { const keys: pathToRegexp.Key[] = [] // options only use `delimiter` const tokens = pathToRegexp.parse(record.path, options) @@ -302,210 +281,3 @@ export function createRouteMatcher( score, } } - -export class RouterMatcher { - private matchers: RouteMatcher[] = [] - - constructor(routes: RouteRecord[]) { - for (const route of routes) { - this.addRouteRecord(route) - } - } - - private addRouteRecord( - record: Readonly, - parent?: RouteMatcher - ): void { - const options: pathToRegexp.RegExpOptions = { - // NOTE: should we make strict by default and redirect /users/ to /users - // so that it's the same from SEO perspective? - strict: false, - } - - // generate an array of records to correctly handle aliases - const normalizedRecords = [normalizeRecord(record)] - if ('alias' in record && record.alias) { - const aliases = - typeof record.alias === 'string' ? [record.alias] : record.alias - for (const alias of aliases) { - const copyForAlias = normalizeRecord(record) - copyForAlias.path = alias - normalizedRecords.push(copyForAlias) - } - } - - if (parent) { - // if the child isn't an absolute route - if (record.path[0] !== '/') { - let path = parent.record.path - // only add the / delimiter if the child path isn't empty - for (const normalizedRecord of normalizedRecords) { - if (normalizedRecord.path) path += '/' - path += record.path - normalizedRecord.path = path - } - } - } - - for (const normalizedRecord of normalizedRecords) { - // create the object before hand so it can be passed to children - const matcher = createRouteMatcher(normalizedRecord, parent, options) - - if ('children' in record && record.children) { - for (const childRecord of record.children) { - this.addRouteRecord(childRecord, matcher) - } - // TODO: the parent is special, we should match their children. They - // reference to the parent so we can render the parent - // - // matcher.score = -10 - } - - this.insertMatcher(matcher) - } - } - - private insertMatcher(matcher: RouteMatcher) { - let i = 0 - while (i < this.matchers.length && matcher.score <= this.matchers[i].score) - i++ - this.matchers.splice(i, 0, matcher) - } - - /** - * Resolve a location without doing redirections so it can be used for anchors - */ - resolveAsPath() {} - - /** - * Transforms a MatcherLocation object into a normalized location - * @param location MatcherLocation to resolve to a url - * @param currentLocation MatcherLocationNormalized of the current location - */ - resolve( - location: Readonly, - currentLocation: Readonly - ): MatcherLocationNormalized | MatcherLocationRedirect { - let matcher: RouteMatcher | void - let params: RouteParams = {} - let path: MatcherLocationNormalized['path'] - let name: MatcherLocationNormalized['name'] - - if ('name' in location && location.name) { - matcher = this.matchers.find(m => m.record.name === location.name) - - if (!matcher) throw new NoRouteMatchError(currentLocation, location) - - name = matcher.record.name - // TODO: merge params - params = location.params || currentLocation.params - // params are automatically encoded - // TODO: try catch to provide better error messages - path = matcher.resolve(params) - // TODO: check missing params - - if ('redirect' in matcher.record) { - const { redirect } = matcher.record - return { - redirect, - normalizedLocation: { - name, - path, - matched: [], - params, - meta: matcher.record.meta || {}, - }, - } - } - } else if ('path' in location) { - matcher = this.matchers.find(m => m.re.test(location.path)) - - // TODO: if no matcher, return the location with an empty matched array - // to allow non existent matches - // TODO: warning of unused params if provided - if (!matcher) throw new NoRouteMatchError(currentLocation, location) - - // no need to resolve the path with the matcher as it was provided - // this also allows the user to control the encoding - path = location.path - name = matcher.record.name - - // fill params - const result = matcher.re.exec(path) - - if (!result) { - // TODO: redo message: matching path against X - throw new Error(`Error parsing path "${location.path}"`) - } - - for (let i = 0; i < matcher.keys.length; i++) { - const key = matcher.keys[i] - let value: string = result[i + 1] - try { - value = decodeURIComponent(value) - } catch (err) { - if (err instanceof URIError) { - console.warn( - `[vue-router] failed decoding param "${key}" with value "${value}". When providing a string location or the "path" property, URL must be properly encoded (TODO: link). Falling back to unencoded value` - ) - } else { - throw err - } - } - if (!value) { - // TODO: handle optional params - throw new Error( - `Error parsing path "${location.path}" when looking for param "${key}"` - ) - } - params[key] = value - } - - if ('redirect' in matcher.record) { - const { redirect } = matcher.record - return { - redirect, - normalizedLocation: { - name, - path, - matched: [], - params, - meta: matcher.record.meta || {}, - }, - } - } - // location is a relative path - } else { - // match by name or path of current route - matcher = currentLocation.name - ? this.matchers.find(m => m.record.name === currentLocation.name) - : this.matchers.find(m => m.re.test(currentLocation.path)) - if (!matcher) throw new NoRouteMatchError(currentLocation, location) - name = matcher.record.name - params = location.params || currentLocation.params - path = matcher.resolve(params) - } - - // this should never happen because it will mean that the user ended up in a route - // that redirects but ended up not redirecting - if ('redirect' in matcher.record) throw new InvalidRouteMatch(location) - - const matched: MatcherLocationNormalized['matched'] = [matcher.record] - let parentMatcher: RouteMatcher | void = matcher.parent - while (parentMatcher) { - // reversed order so parents are at the beginning - // TODO: should be doable by typing RouteMatcher in a different way - if ('redirect' in parentMatcher.record) throw new Error('TODO') - matched.unshift(parentMatcher.record) - parentMatcher = parentMatcher.parent - } - - return { - name, - path, - params, - matched, - meta: matcher.record.meta || {}, - } - } -} diff --git a/src/matcher/types.ts b/src/matcher/types.ts new file mode 100644 index 00000000..851d3773 --- /dev/null +++ b/src/matcher/types.ts @@ -0,0 +1,22 @@ +import { + RouteParams, + RouteRecordMultipleViews, + RouteRecordRedirect, +} from '../types' + +// normalize component/components into components +export type NormalizedRouteRecord = + | Omit + | Omit + +export interface RouteRecordMatcher { + re: RegExp + resolve: (params?: RouteParams) => string + record: NormalizedRouteRecord + parent: RouteRecordMatcher | void + // TODO: children so they can be removed + // children: RouteMatcher[] + // TODO: needs information like optional, repeatable + keys: string[] + score: number +}