Skip to content

Commit

Permalink
feat(router): replace path-to-regexp with internal matcher (#64)
Browse files Browse the repository at this point in the history
Closes #58
  • Loading branch information
meeroslav committed Dec 28, 2020
1 parent 9ed17ec commit d34dfc0
Show file tree
Hide file tree
Showing 8 changed files with 227 additions and 47 deletions.
1 change: 0 additions & 1 deletion libs/angular-routing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"rxjs": ">=6.5.3"
},
"dependencies": {
"path-to-regexp": "^6.1.0",
"query-string": "^6.13.1"
},
"sideEffects": false
Expand Down
56 changes: 28 additions & 28 deletions libs/angular-routing/src/lib/router.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,11 @@ import {
debounceTime,
map,
} from 'rxjs/operators';

import { pathToRegexp, match } from 'path-to-regexp';

import { Route, ActiveRoute } from './route';
import { Router } from './router.service';
import { compareParams, Params } from './route-params.service';
import { compareParams } from './route-params.service';
import { compareRoutes } from './utils/compare-routes';
import { matchRoute, parsePath } from './utils/path-parser';

interface State {
activeRoute: ActiveRoute | null;
Expand Down Expand Up @@ -47,6 +45,7 @@ export class RouterComponent implements OnInit, OnDestroy {
);
readonly routes$ = this.state$.pipe(
map((state) => state.routes),
distinctUntilChanged(this.compareRoutes),
takeUntil(this.destroy$)
);

Expand All @@ -72,7 +71,7 @@ export class RouterComponent implements OnInit, OnDestroy {
tap(([routes, url]: [Route[], string]) => {
let routeToRender = null;
for (const route of routes) {
routeToRender = this.findRouteMatch(route, url);
routeToRender = this.isRouteMatch(url, route);

if (routeToRender) {
this.setRoute(url, route);
Expand All @@ -89,34 +88,18 @@ export class RouterComponent implements OnInit, OnDestroy {
.subscribe();
}

findRouteMatch(route: Route, url: string) {
const matchedRoute = route.matcher ? route.matcher.exec(url) : null;

if (matchedRoute) {
return matchedRoute;
}

return null;
}

setRoute(url: string, route: Route) {
const pathInfo = match(this.normalizePath(route.path), {
end: route.options.exact,
})(url);
this.basePath = route.path;

const routeParams: Params = pathInfo ? pathInfo.params : {};
const path: string = pathInfo ? pathInfo.path : '';
this.setActiveRoute({ route, params: routeParams || {}, path });
const match = matchRoute(url, route);
this.setActiveRoute({
route,
params: match?.params || {},
path: match?.path || '',
});
}

registerRoute(route: Route) {
const normalized = this.normalizePath(route.path);
const routeRegex = pathToRegexp(normalized, [], {
end: route.options.exact ?? true,
});

route.matcher = route.matcher || routeRegex;
route.matcher = route.matcher || parsePath(route);
this.updateRoutes(route);

return route;
Expand All @@ -138,6 +121,10 @@ export class RouterComponent implements OnInit, OnDestroy {
this.destroy$.next();
}

private isRouteMatch(url: string, route: Route) {
return route.matcher?.exec(url);
}

private compareActiveRoutes(
previous: ActiveRoute,
current: ActiveRoute
Expand All @@ -156,6 +143,19 @@ export class RouterComponent implements OnInit, OnDestroy {
);
}

private compareRoutes(previous: Route[], current: Route[]): boolean {
if (previous === current) {
return true;
}
if (!previous) {
return false;
}
return (
previous.length === current.length &&
previous.every((route, i) => route[i] === current[i])
);
}

private updateState(newState: Partial<State>) {
this.state$.next({ ...this.state$.value, ...newState });
}
Expand Down
10 changes: 2 additions & 8 deletions libs/angular-routing/src/lib/utils/compare-routes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { pathToRegexp } from 'path-to-regexp';
import { Route } from '../route';
import { compareRoutes } from './compare-routes';
import { parsePath } from './path-parser';

describe('compareRoutes', () => {
it('should return 0 if matchers are same', () => {
Expand Down Expand Up @@ -60,12 +60,6 @@ describe('compareRoutes', () => {
});

function makeRoute(route: Route): Route {
route.matcher = pathToRegexp(normalizePath(route), [], {
end: route.options.exact ?? true,
});
route.matcher = parsePath(route);
return route;
}

function normalizePath(route: Route): string {
return route.path.startsWith('/') ? route.path : `/${route.path}`;
}
5 changes: 1 addition & 4 deletions libs/angular-routing/src/lib/utils/compare-routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Route } from '../route';
import { getPathSegments } from './path-parser';

/**
* Compares two routes and returns sorting number
Expand Down Expand Up @@ -27,10 +28,6 @@ export const compareRoutes = (a: Route, b: Route): number => {
return a.options.exact ?? true ? -1 : 1;
};

function getPathSegments(route: Route): string[] {
return route.path.replace(/^\//, '').split('/');
}

function compareSegments(
aSegments: string[],
bSegments: string[],
Expand Down
133 changes: 133 additions & 0 deletions libs/angular-routing/src/lib/utils/path-parser.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Route } from '../route';
import { matchRoute, parsePath } from './path-parser';

describe('parsePath', () => {
it('should parse empty route', () => {
expect(parsePath({ path: '', options: {} })).toEqual(/^[\/#\?]?$/i);
expect(parsePath({ path: '/', options: {} })).toEqual(/^[\/#\?]?$/i);
});
it('should parse empty wildcard route', () => {
expect(parsePath({ path: '', options: { exact: false } })).toEqual(
/^(?:[\/#\?](?=[]|$))?/i
);
expect(parsePath({ path: '/', options: { exact: false } })).toEqual(
/^(?:[\/#\?](?=[]|$))?/i
);
});
it('should parse static route', () => {
expect(parsePath({ path: 'first/second', options: {} })).toEqual(
/^\/first\/second[\/#\?]?$/i
);
expect(parsePath({ path: '/first/second', options: {} })).toEqual(
/^\/first\/second[\/#\?]?$/i
);
});
it('should remove ending slash', () => {
expect(parsePath({ path: 'first/', options: {} })).toEqual(
/^\/first[\/#\?]?$/i
);
expect(parsePath({ path: '/first/', options: {} })).toEqual(
/^\/first[\/#\?]?$/i
);
});
it('should parse static wildcard route', () => {
expect(
parsePath({ path: 'first/second', options: { exact: false } })
).toEqual(/^\/first\/second(?:[\/#\?](?=[]|$))?(?=[\/#\?]|[]|$)/i);
expect(
parsePath({ path: '/first/second', options: { exact: false } })
).toEqual(/^\/first\/second(?:[\/#\?](?=[]|$))?(?=[\/#\?]|[]|$)/i);
});

it('should parse dynamic route', () => {
expect(parsePath({ path: ':id', options: {} })).toEqual(
/^(?:\/([^\/#\?]+?))[\/#\?]?$/i
);
expect(parsePath({ path: '/books/:bookId', options: {} })).toEqual(
/^\/books(?:\/([^\/#\?]+?))[\/#\?]?$/i
);
});

it('should parse dynamic wildcard route', () => {
expect(parsePath({ path: ':id', options: { exact: false } })).toEqual(
/^(?:\/([^\/#\?]+?))(?:[\/#\?](?=[]|$))?(?=[\/#\?]|[]|$)/i
);
expect(
parsePath({ path: '/books/:bookId', options: { exact: false } })
).toEqual(
/^\/books(?:\/([^\/#\?]+?))(?:[\/#\?](?=[]|$))?(?=[\/#\?]|[]|$)/i
);
});
});

describe('matchRoute', () => {
it('should match wildcard route', () => {
const route: Route = { path: '', options: { exact: false } };
route.matcher = parsePath(route);

expect(matchRoute('/', route)).toEqual({ path: '/', params: {} });
expect(matchRoute('/first', route)).toEqual({ path: '', params: {} });
expect(matchRoute('/first/second/third', route)).toEqual({
path: '',
params: {},
});
});
it('should match empty route', () => {
const route: Route = { path: '', options: {} };
route.matcher = parsePath(route);

expect(matchRoute('/', route)).toEqual({ path: '/', params: {} });
expect(matchRoute('/first', route)).not.toBeDefined();
expect(matchRoute('/first/second', route)).not.toBeDefined();
});
it('should match static wildcard route', () => {
const route: Route = { path: 'first/second', options: { exact: false } };
route.matcher = parsePath(route);

expect(matchRoute('/first/second', route)).toEqual({
path: '/first/second',
params: {},
});
expect(matchRoute('/first', route)).not.toBeDefined();
expect(matchRoute('/first/second/third', route)).toEqual({
path: '/first/second',
params: {},
});
});
it('should match static route', () => {
const route: Route = { path: 'first/second', options: {} };
route.matcher = parsePath(route);

expect(matchRoute('/first/second', route)).toEqual({
path: '/first/second',
params: {},
});
expect(matchRoute('/first', route)).not.toBeDefined();
expect(matchRoute('/first/second/third', route)).not.toBeDefined();
});
it('should match dynamic wildcard route', () => {
const route: Route = { path: 'first/:id', options: { exact: false } };
route.matcher = parsePath(route);

expect(matchRoute('/first/second', route)).toEqual({
path: '/first/second',
params: { id: 'second' },
});
expect(matchRoute('/first', route)).not.toBeDefined();
expect(matchRoute('/first/second/third', route)).toEqual({
path: '/first/second',
params: { id: 'second' },
});
});
it('should match dynamic route', () => {
const route: Route = { path: 'first/:id/:name', options: {} };
route.matcher = parsePath(route);

expect(matchRoute('/first/second', route)).not.toBeDefined();
expect(matchRoute('/first', route)).not.toBeDefined();
expect(matchRoute('/first/second/third', route)).toEqual({
path: '/first/second/third',
params: { id: 'second', name: 'third' },
});
});
});
63 changes: 63 additions & 0 deletions libs/angular-routing/src/lib/utils/path-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Route } from '../route';
import { Params } from '../route-params.service';

const PARAM_PREFIX = ':';

export interface RouteMatch {
path: string;
params: Params;
}

const DIV = '\\/'; // /
const DIV_PARAM = `(?:${DIV}([^\\/#\\?]+?))`; // capturing group for one or more of not (/, # or ?), optional (TODO: check if optional is needed)
const PATH_END = '[\\/#\\?]'; // path end: /, # or ?
const END = '[]|$'; // null or end
const EXACT_END = `${PATH_END}?$`; // match PATH_END optionally and END
const WILDCARD = `(?:${PATH_END}(?=${END}))?`; // match optionally PATH_END followed by END
const NON_EXACT_END = `${WILDCARD}(?=${PATH_END}|${END})`; // match WILDCARD followed by PATH_END or END

export function getPathSegments(route: Route): string[] {
const sanitizedPath = route.path.replace(/^\//, '').replace(/(?:\/$)/, '');
return sanitizedPath ? sanitizedPath.split('/') : [];
}

export const parsePath = (route: Route): RegExp => {
const segments = getPathSegments(route);
const regexBody = segments.reduce(
(acc, segment) =>
segment.startsWith(PARAM_PREFIX)
? `${acc}${DIV_PARAM}`
: `${acc}${DIV}${segment}`,
''
);

if (route.options.exact ?? true) {
return new RegExp(`^${regexBody}${EXACT_END}`, 'i');
} else {
return new RegExp(
`^${regexBody}${regexBody ? NON_EXACT_END : WILDCARD}`,
'i'
);
}
};

export const matchRoute = (
url: string,
route: Route
): RouteMatch | undefined => {
const match = route.matcher?.exec(url);
if (!match) {
return;
}
const keys = getPathSegments(route)
.filter((s) => s.startsWith(PARAM_PREFIX))
.map((s) => s.slice(1));

return {
path: match[0],
params: keys.reduce(
(acc, key, index) => ({ ...acc, [key]: match[index + 1] }),
{}
),
};
};
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
"@nrwl/angular": "^9.4.5",
"angular-in-memory-web-api": "^0.11.0",
"hammerjs": "^2.0.8",
"path-to-regexp": "^6.1.0",
"query-string": "^6.13.1",
"rxjs": "~6.5.4",
"tslib": "^1.10.0",
Expand Down
5 changes: 0 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8872,11 +8872,6 @@ path-to-regexp@0.1.7:
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=

path-to-regexp@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.1.0.tgz#0b18f88b7a0ce0bfae6a25990c909ab86f512427"
integrity sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==

path-type@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
Expand Down

0 comments on commit d34dfc0

Please sign in to comment.