Skip to content

Commit

Permalink
feat(patternMatching): Switch to path-to-regexp for pattern matching (n…
Browse files Browse the repository at this point in the history
…grx#57)

path-to-regexp offers a broader pattern matching feature set with better performance. This replaces react-router's home grown pattern matching with path-to-regexp. For more information on the capabilities of path-to-regexp's pattern matching, checkout the documentation: https://github.com/pillarjs/path-to-regexp/blob/master/Readme.md#parameters
MikeRyanDev authored and brandonroberts committed Apr 13, 2016
1 parent 5b14ef9 commit 4176112
Showing 13 changed files with 249 additions and 242 deletions.
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
The MIT License (MIT)

Copyright (c) 2016 Brandon Roberts, Mike Ryan
Portions Copyright (c) 2015 Ryan Florence, Michael Jackson
Portions Copyright (c) 2015 Ryan Florence, Michael Jackson, Jimmy Jia

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
1 change: 0 additions & 1 deletion docs/overview/README.md
Original file line number Diff line number Diff line change
@@ -99,7 +99,6 @@ That's it! You are ready to begin taking advantage of reactive routing!

* [Route Configuration](route.md)
* [Route Links](links.md)
* [Index Routes](index-route.md)
* [Redirects](redirect.md)
* [Code Splitting](code-splitting.md)
* [Route and Query Parameters](params.md)
37 changes: 0 additions & 37 deletions docs/overview/index-route.md

This file was deleted.

67 changes: 66 additions & 1 deletion docs/overview/route.md
Original file line number Diff line number Diff line change
@@ -76,7 +76,7 @@ const routes: Routes = [
}
]
}
]
];
```

To get this working, all we have to do is provide the router with the route configuration when we bootstrap the application:
@@ -121,3 +121,68 @@ Then in our `App` template we give each `<route-view />` a name:
})
export class App { }
```

# Index Routes
Imagine if you wanted to show a list of posts when the user is just on `/blog`. When the user is on `/blog/:id`, don't show the list of posts and instead just show a single post. This can be accomplished using _Index Routes_.

First we need to write a new `BlogPostsPage` component:

```ts
import { Component } from 'angular2/core';

@Component({
selector: 'blog-posts-page',
template: `
<h3>All Posts</h3>
`
})
class BlogPostsPage { }
```

Now we just need to rewrite the `/blog` route configuration to specify an index route:

```ts
const routes: Routes = [
{
path: '/blog',
component: BlogPage
indexRoute: {
component: BlogPostsPage
},
children: [
{
path: ':id',
component: PostPage
}
]
}
]
```

## Pathless Routes
Sometimes you want to share a common component and/or guards with a group of routes. To achieve this with @ngrx/router, you can write a _pathless_ route. Pathless routes have all of the features of a regular route except they do not define a path, preventing them from being routed to directly.

Expanding on our blog example, lets change `/blog/:id` to `/posts/:id` but still preserve the same component tree:

```ts
import { Routes } from '@ngrx/router';

const routes: Routes = [
{
component: BlogPage,
children: [
{
path: '/blog',
component: BlogPostsPage
},
{
path: '/posts/:id',
component: PostPage
}
]
}
];
```

## Pattern Matching
Pattern matching in @ngrx/router is built on top of [path-to-regexp](https://github.com/pillarjs/path-to-regexp), a popular pattern matching library used by a large number of popular router projects like Express and Koa. For more information on what patterns @ngrx/router supports, checkout the [path-to-regexp documentation](https://github.com/pillarjs/path-to-regexp/blob/master/Readme.md#parameters). To play with pattern matching, use the [Express Route Tester](http://forbeslindesay.github.io/express-route-tester/?_ga=1.61903652.927460731.1460569779)
1 change: 0 additions & 1 deletion esdoc.json
Original file line number Diff line number Diff line change
@@ -14,7 +14,6 @@
"./docs/overview/params.md",
"./docs/overview/redirect.md",
"./docs/overview/guards.md",
"./docs/overview/index-route.md",
"./docs/overview/code-splitting.md"
],
"changelog": [
235 changes: 46 additions & 189 deletions lib/match-pattern.ts
Original file line number Diff line number Diff line change
@@ -1,233 +1,90 @@
/**
* This is a straight up copy of react-router's PatternUtils. It may be worth
* investigating if the react-router team is open to splitting this out
* into a separate package
*/
import * as pathToRegexp from 'path-to-regexp';

export interface Params {
[param: string]: string | string[];
}

function escapeRegExp(input: string) {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function escapeSource(input: string) {
return escapeRegExp(input).replace(/\/+/g, '/+');
}

export interface CompiledPattern {
pattern: string;
regexpSource: string;
paramNames: string[];
tokens: string[];
}

function _compilePattern(pattern: string): CompiledPattern {
let regexpSource = '';
const paramNames: string[] = [];
const tokens: string[] = [];

let match: RegExpExecArray;
let lastIndex = 0;
const matcher = /:([a-zA-Z_$][a-zA-Z0-9_$]*)|\*\*|\*|\(|\)/g;
const REGEXP_CACHE = new Map<string, { regexp: RegExp, keys: any[] }>();
const COMPILED_CACHE = new Map<string, (params: Params) => string>();

while ((match = matcher.exec(pattern))) {
if (match.index !== lastIndex) {
tokens.push(pattern.slice(lastIndex, match.index));
regexpSource += escapeSource(pattern.slice(lastIndex, match.index));
}

if ( match[1] ) {
regexpSource += '([^/]+)';
paramNames.push(match[1]);
}
else if ( match[0] === '**' ) {
regexpSource += '(.*)';
paramNames.push('splat');
}
else if ( match[0] === '*' ) {
regexpSource += '(.*?)';
paramNames.push('splat');
}
else if ( match[0] === '(' ) {
regexpSource += '(?:';
}
else if ( match[0] === ')' ) {
regexpSource += ')?';
}

tokens.push(match[0]);
lastIndex = matcher.lastIndex;
}

if ( lastIndex !== pattern.length ) {
tokens.push(pattern.slice(lastIndex, pattern.length));
regexpSource += escapeSource(pattern.slice(lastIndex, pattern.length));
export function getRegexp(pattern: string) {
if (!REGEXP_CACHE.has(pattern)) {
const keys = [];
const regexp = pathToRegexp(pattern, keys, { end: false });
REGEXP_CACHE.set(pattern, { keys, regexp });
}

return {
pattern,
regexpSource,
paramNames,
tokens
};
return REGEXP_CACHE.get(pattern);
}

const CompiledPatternsCache: { [pattern: string]: CompiledPattern } = {};

export function compilePattern(pattern) {
if ( !(pattern in CompiledPatternsCache) ) {
CompiledPatternsCache[pattern] = _compilePattern(pattern);
export function getCompiled(pattern: string) {
if (!COMPILED_CACHE.has(pattern)) {
COMPILED_CACHE.set(pattern, pathToRegexp.compile(pattern));
}


return CompiledPatternsCache[pattern];
return COMPILED_CACHE.get(pattern);
}

/**
* Attempts to match a pattern on the given pathname. Patterns may use
* the following special characters:
*
* - :paramName Matches a URL segment up to the next /, ?, or #. The
* captured string is considered a "param"
* - () Wraps a segment of the URL that is optional
* - * Consumes (non-greedy) all characters up to the next
* character in the pattern, or to the end of the URL if
* there is none
* - ** Consumes (greedy) all characters up to the next character
* in the pattern, or to the end of the URL if there is none
*
* The return value is an object with the following properties:
*
* - remainingPathname
* - paramNames
* - paramValues
*/
export function matchPattern(pattern: string, pathname: string) {
// Make leading slashes consistent between pattern and pathname.
if ( pattern.charAt(0) !== '/' ) {
pattern = `/${pattern}`;
}
if ( pathname.charAt(0) !== '/' ) {
pathname = `/${pathname}`;
}

let { regexpSource, paramNames, tokens } = compilePattern(pattern);
const compiled = getRegexp(pattern);
const match = compiled.regexp.exec(pathname);

regexpSource += '/*'; // Capture path separators

// Special-case patterns like '*' for catch-all routes.
if (tokens[tokens.length - 1] === '*') {
regexpSource += '$';
}

const match = pathname.match(new RegExp(`^${regexpSource}`, 'i'));

let remainingPathname: string;
let paramValues: string[];

if ( match != null ) {
const matchedPath = match[0];
remainingPathname = pathname.substr(matchedPath.length);

// If we didn't match the entire pathname, then make sure that the match we
// did get ends at a path separator (potentially the one we added above at
// the beginning of the path, if the actual match was empty).
if (
remainingPathname &&
matchedPath.charAt(matchedPath.length - 1) !== '/'
) {
return {
remainingPathname: null,
paramNames,
paramValues: null
};
}

paramValues = match.slice(1).map(v => v && decodeURIComponent(v));
}
else {
remainingPathname = paramValues = null;
if (!match) {
return {
remainingPathname: null,
paramNames: [],
paramValues: []
};
}

return {
remainingPathname,
paramNames,
paramValues
remainingPathname: pathname.substr(match[0].length),
paramNames: compiled.keys.map(({ name }) => name),
paramValues: match.slice(1).map(value => value && decodeURIComponent(value))
};
}

export function getParamNames(pattern: string) {
return compilePattern(pattern).paramNames;
return getRegexp(pattern).keys.map(({ name }) => name);
}

export function makeParams(paramNames: (string | number)[], paramValues: any[]): Params {
const params: Params = {};
let lastIndex = 0;

paramNames.forEach(function(paramName, index) {
if (typeof paramName === 'number') {
paramName = lastIndex++;
}

params[paramName] = paramValues && paramValues[index];
});

return params;
}

export function getParams(pattern: string, pathname: string) {
const { paramNames, paramValues } = matchPattern(pattern, pathname);
const { remainingPathname, paramNames, paramValues } = matchPattern(pattern, pathname);

if (paramValues != null) {
return paramNames.reduce(function (memo, paramName, index) {
memo[paramName] = paramValues[index];
return memo;
}, {});
if (remainingPathname === null) {
return null;
}

return null;
return makeParams(paramNames, paramValues);
}

/**
* Returns a version of the given pattern with params interpolated. Throws
* if there is a dynamic segment of the pattern for which there is no param.
*/
export function formatPattern(pattern: string, params: Params = {}) {
const { tokens } = compilePattern(pattern);
let parenCount = 0;
let pathname = '';
let splatIndex = 0;

let token: string;
let paramName: string;
let paramValue;

for (let i = 0, len = tokens.length; i < len; ++i) {
token = tokens[i];

if (token === '*' || token === '**') {
paramValue = Array.isArray(params['splat']) ?
params['splat'][splatIndex++] :
params['splat'];

if ( paramValue != null || parenCount > 0 ) {
console.error('Missing splat #%s for path "%s"', splatIndex, pattern);
}

if ( paramValue != null ) {
pathname += encodeURI(paramValue);
}
}
else if ( token === '(' ) {
parenCount += 1;
}
else if ( token === ')' ) {
parenCount -= 1;
}
else if ( token.charAt(0) === ':' ) {
paramName = token.substring(1);
paramValue = params[paramName];

if ( !(paramValue != null || parenCount > 0) ) {
console.error('Missing "%s" parameter for path "%s"',
paramName, pattern);
}

if ( paramValue != null ) {
pathname += encodeURIComponent(paramValue);
}
}
else {
pathname += token;
}
}

return pathname.replace(/\/+/g, '/');
export function formatPattern(pattern: string, params: Params = {}) {
return getCompiled(pattern)(params);
}
4 changes: 2 additions & 2 deletions lib/route-traverser.ts
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@ import { OpaqueToken, Provider, Inject, Injectable } from 'angular2/core';

import { ResourceLoader, Async } from './resource-loader';
import { compose } from './util';
import { matchPattern } from './match-pattern';
import { matchPattern, makeParams } from './match-pattern';
import { Route, IndexRoute, Routes, ROUTES } from './route';
import { Middleware, provideMiddlewareForToken, identity } from './middleware';

@@ -109,7 +109,7 @@ export class RouteTraverser {
.map<TraversalCandidate>(() => {
return {
route,
params: assignParams(paramNames, paramValues),
params: makeParams(paramNames, paramValues),
isTerminal: remainingPathname === '' && !!route.path
};
})
31 changes: 31 additions & 0 deletions manual-typings/path-to-regexp.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
declare module 'path-to-regexp' {
function pathToRegexp(path: string, values: any[], options?: pathToRegexp.IOptions): RegExp;

namespace pathToRegexp {
interface IOptions {
sensitive?: boolean;
strict?: boolean;
end?: boolean;
}

interface IToken {
name: string | number;
prefix: string;
delimiter: string;
optional: boolean;
repeat: boolean;
pattern: string;
}

interface CompiledRegExp extends RegExp {
keys: IToken[];
}

function parse(path: string): Array<string | IToken>;
function compile(path: string): (params: any) => string;
function tokensToRegExp(tokens: Array<string | IToken>): RegExp;
function tokensToFunction(tokens: Array<string | IToken>): (params: any) => string;
}

export = pathToRegexp;
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -67,6 +67,7 @@
"zone.js": "^0.6.8"
},
"dependencies": {
"path-to-regexp": "^1.2.1",
"query-string": "^4.1.0"
}
}
19 changes: 15 additions & 4 deletions spec/match-pattern.spec.ts
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@ describe('matchPattern', function() {
}

it('works without params', function() {
assertMatch('/', '/path', 'path', [], []);
assertMatch('/', '/path', '/path', [], []);
});

it('works with named params', function() {
@@ -24,18 +24,29 @@ describe('matchPattern', function() {
});

it('works with splat params', function() {
assertMatch('/files/*.*', '/files/path.jpg', '', [ 'splat', 'splat' ], [ 'path', 'jpg' ]);
assertMatch('/files/*.*', '/files/path.jpg', '', [ 0, 1 ], [ 'path', 'jpg' ]);
});

it('ignores trailing slashes', function() {
assertMatch('/:id', '/path/', '', [ 'id' ], [ 'path' ]);
});

it('works with greedy splat (**)', function() {
assertMatch('/**/g', '/greedy/is/good/g', '', [ 'splat' ], [ 'greedy/is/good' ]);
assertMatch('/*/g', '/greedy/is/good/g', '', [ 0 ], [ 'greedy/is/good' ]);
});

it('works with greedy and non-greedy splat', function() {
assertMatch('/**/*.jpg', '/files/path/to/file.jpg', '', [ 'splat', 'splat' ], [ 'files/path/to', 'file' ]);
assertMatch('/*/*.jpg', '/files/path/to/file.jpg', '', [ 0, 1 ], [ 'files/path/to', 'file' ]);
});

it('works with regexes for params', function() {
assertMatch('/:int(\\d+)', '/42', '', [ 'int' ], [ '42' ]);
assertMatch('/:id(foo|bar)', '/foo', '', [ 'id' ], [ 'foo' ]);
assertMatch('/:id(foo|bar)', '/foo', '', [ 'id' ], [ 'foo' ]);
});

it('works with anonymous params', function() {
assertMatch('/(foo|bar)', '/foo', '', [ 0 ], [ 'foo' ]);
assertMatch('/(foo|bar)', '/bar', '', [ 0 ], [ 'bar' ]);
});
});
86 changes: 82 additions & 4 deletions spec/route-traverser.spec.ts
Original file line number Diff line number Diff line change
@@ -21,6 +21,11 @@ describe('RouteTraverser', function() {
let OptionalRoute: Route;
let OptionalRouteChild: Route;
let CatchAllRoute: Route;
let RegexRoute: Route;
let UnnamedParamsRoute: Route;
let UnnamedParamsRouteChild: Route;
let PathlessRoute: Route;
let PathlessChildRoute: Route;

let routes: Routes = [
RootRoute = {
@@ -54,16 +59,34 @@ describe('RouteTraverser', function() {
path: '/about'
},
GreedyRoute = {
path: '/**/f'
path: '/*/f'
},
OptionalRoute = {
path: '/(optional)',
path: '/(optional)?',
children: [
OptionalRouteChild = {
path: 'child'
}
]
},
RegexRoute = {
path: '/int/:int(\\d+)'
},
UnnamedParamsRoute = {
path: '/unnamed-params/(foo)',
children: [
UnnamedParamsRouteChild = {
path: '(bar)'
}
]
},
PathlessRoute = {
children: [
PathlessChildRoute = {
path: 'pathless-child'
}
]
},
CatchAllRoute = {
path: '*'
}
@@ -127,7 +150,7 @@ describe('RouteTraverser', function() {
.subscribe(match => {
expect(match).toBeDefined();
expect(match.routes).toEqual([ FilesRoute ]);
expect(match.params).toEqual({ splat: [ 'a', 'b/c' ] });
expect(match.params).toEqual({ 0: 'a/b', 1: 'c' });

done();
});
@@ -141,7 +164,7 @@ describe('RouteTraverser', function() {
.subscribe(match => {
expect(match).toBeDefined();
expect(match.routes).toEqual([ GreedyRoute ]);
expect(match.params).toEqual({ splat: 'foo/bar' });
expect(match.params).toEqual({ 0: 'foo/bar' });

done();
});
@@ -242,6 +265,61 @@ describe('RouteTraverser', function() {
done();
});
});

it('matches the "catch-all" route on a regex miss', function(done) {
traverser
.find('/int/foo')
.subscribe(match => {
expect(match).toBeDefined();
expect(match.routes).toEqual([ CatchAllRoute ]);

done();
});
});
});

describe('when the location matches a route with param regex', function() {
it('matches the correct routes and param', function(done) {
traverser
.find('/int/42')
.subscribe(match => {
expect(match).toBeDefined();
expect(match.routes).toEqual([ RegexRoute ]);
expect(match.params).toEqual({ int: '42' });

done();
});
});
});

describe('when the location matches a nested route with an unnamed param', function() {
it('matches the correct routes and params', function(done) {
traverser
.find('/unnamed-params/foo/bar')
.subscribe(match => {
expect(match).toBeDefined();
expect(match.routes).toEqual([ UnnamedParamsRoute, UnnamedParamsRouteChild ]);
expect(match.params).toEqual({ 0: 'foo', 1: 'bar' });

done();
});
});
});

describe('when the location matches pathless routes', function() {
it('matches the correct routes', function(done) {
traverser
.find('/pathless-child')
.subscribe(match => {
expect(match).toBeDefined();
expect(match.routes).toEqual([
PathlessRoute,
PathlessChildRoute
]);

done();
});
});
});
}

6 changes: 4 additions & 2 deletions spec/tsconfig.json
Original file line number Diff line number Diff line change
@@ -11,7 +11,8 @@
},
"filesGlob": [
"./**/*.ts",
"../typings/main.d.ts"
"../typings/main.d.ts",
"../manual-typings/path-to-regexp.d.ts"
],
"files": [
"./component-renderer.spec.ts",
@@ -29,7 +30,8 @@
"./route.spec.ts",
"./router.spec.ts",
"./util.spec.ts",
"../typings/main.d.ts"
"../typings/main.d.ts",
"../manual-typings/path-to-regexp.d.ts"
],
"atom": {
"rewriteTsconfig": true
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@
},
"files": [
"typings/main/ambient/query-string/index.d.ts",
"manual-typings/path-to-regexp.d.ts",
"lib/index.ts"
]
}

0 comments on commit 4176112

Please sign in to comment.