Brian Ford 75343eb340 feat(router): add regex matchers
@petebacondarwin deserves credit for most of this commit.

This allows you to specify a regex and serializer function instead
of the path DSL in your route declaration.

```
@RouteConfig([
  { regex: '[a-z]+.[0-9]+',
    serializer: (params) => `{params.a}.params.b}`,
    component: MyComponent }
])
class Component {}
```

Closes #7325
Closes #7126
2016-03-02 16:08:19 -08:00

558 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {ListWrapper, Map, MapWrapper, StringMapWrapper} from 'angular2/src/facade/collection';
import {PromiseWrapper} from 'angular2/src/facade/async';
import {
isPresent,
isArray,
isBlank,
isType,
isString,
isStringMap,
Type,
StringWrapper,
Math,
getTypeNameForDebugging,
CONST_EXPR
} from 'angular2/src/facade/lang';
import {BaseException, WrappedException} from 'angular2/src/facade/exceptions';
import {reflector} from 'angular2/src/core/reflection/reflection';
import {Injectable, Inject, OpaqueToken} from 'angular2/core';
import {
RouteConfig,
AsyncRoute,
Route,
AuxRoute,
Redirect,
RouteDefinition
} from './route_config/route_config_impl';
import {PathMatch, RedirectMatch, RouteMatch} from './rules/rules';
import {RuleSet} from './rules/rule_set';
import {
Instruction,
ResolvedInstruction,
RedirectInstruction,
UnresolvedInstruction,
DefaultInstruction
} from './instruction';
import {normalizeRouteConfig, assertComponentExists} from './route_config/route_config_normalizer';
import {parser, Url, convertUrlParamsToArray, pathSegmentsToUrl} from './url_parser';
var _resolveToNull = PromiseWrapper.resolve(null);
// A LinkItemArray is an array, which describes a set of routes
// The items in the array are found in groups:
// - the first item is the name of the route
// - the next items are:
// - an object containing parameters
// - or an array describing an aux route
// export type LinkRouteItem = string | Object;
// export type LinkItem = LinkRouteItem | Array<LinkRouteItem>;
// export type LinkItemArray = Array<LinkItem>;
/**
* Token used to bind the component with the top-level {@link RouteConfig}s for the
* application.
*
* ### Example ([live demo](http://plnkr.co/edit/iRUP8B5OUbxCWQ3AcIDm))
*
* ```
* import {Component} from 'angular2/core';
* import {
* ROUTER_DIRECTIVES,
* ROUTER_PROVIDERS,
* RouteConfig
* } from 'angular2/router';
*
* @Component({directives: [ROUTER_DIRECTIVES]})
* @RouteConfig([
* {...},
* ])
* class AppCmp {
* // ...
* }
*
* bootstrap(AppCmp, [ROUTER_PROVIDERS]);
* ```
*/
export const ROUTER_PRIMARY_COMPONENT: OpaqueToken =
CONST_EXPR(new OpaqueToken('RouterPrimaryComponent'));
/**
* The RouteRegistry holds route configurations for each component in an Angular app.
* It is responsible for creating Instructions from URLs, and generating URLs based on route and
* parameters.
*/
@Injectable()
export class RouteRegistry {
private _rules = new Map<any, RuleSet>();
constructor(@Inject(ROUTER_PRIMARY_COMPONENT) private _rootComponent: Type) {}
/**
* Given a component and a configuration object, add the route to this registry
*/
config(parentComponent: any, config: RouteDefinition): void {
config = normalizeRouteConfig(config, this);
// this is here because Dart type guard reasons
if (config instanceof Route) {
assertComponentExists(config.component, config.path);
} else if (config instanceof AuxRoute) {
assertComponentExists(config.component, config.path);
}
var rules = this._rules.get(parentComponent);
if (isBlank(rules)) {
rules = new RuleSet();
this._rules.set(parentComponent, rules);
}
var terminal = rules.config(config);
if (config instanceof Route) {
if (terminal) {
assertTerminalComponent(config.component, config.path);
} else {
this.configFromComponent(config.component);
}
}
}
/**
* Reads the annotations of a component and configures the registry based on them
*/
configFromComponent(component: any): void {
if (!isType(component)) {
return;
}
// Don't read the annotations from a type more than once
// this prevents an infinite loop if a component routes recursively.
if (this._rules.has(component)) {
return;
}
var annotations = reflector.annotations(component);
if (isPresent(annotations)) {
for (var i = 0; i < annotations.length; i++) {
var annotation = annotations[i];
if (annotation instanceof RouteConfig) {
let routeCfgs: RouteDefinition[] = annotation.configs;
routeCfgs.forEach(config => this.config(component, config));
}
}
}
}
/**
* Given a URL and a parent component, return the most specific instruction for navigating
* the application into the state specified by the url
*/
recognize(url: string, ancestorInstructions: Instruction[]): Promise<Instruction> {
var parsedUrl = parser.parse(url);
return this._recognize(parsedUrl, []);
}
/**
* Recognizes all parent-child routes, but creates unresolved auxiliary routes
*/
private _recognize(parsedUrl: Url, ancestorInstructions: Instruction[],
_aux = false): Promise<Instruction> {
var parentInstruction = ListWrapper.last(ancestorInstructions);
var parentComponent = isPresent(parentInstruction) ? parentInstruction.component.componentType :
this._rootComponent;
var rules = this._rules.get(parentComponent);
if (isBlank(rules)) {
return _resolveToNull;
}
// Matches some beginning part of the given URL
var possibleMatches: Promise<RouteMatch>[] =
_aux ? rules.recognizeAuxiliary(parsedUrl) : rules.recognize(parsedUrl);
var matchPromises: Promise<Instruction>[] = possibleMatches.map(
(candidate: Promise<RouteMatch>) => candidate.then((candidate: RouteMatch) => {
if (candidate instanceof PathMatch) {
var auxParentInstructions =
ancestorInstructions.length > 0 ? [ListWrapper.last(ancestorInstructions)] : [];
var auxInstructions =
this._auxRoutesToUnresolved(candidate.remainingAux, auxParentInstructions);
var instruction = new ResolvedInstruction(candidate.instruction, null, auxInstructions);
if (isBlank(candidate.instruction) || candidate.instruction.terminal) {
return instruction;
}
var newAncestorInstructions = ancestorInstructions.concat([instruction]);
return this._recognize(candidate.remaining, newAncestorInstructions)
.then((childInstruction) => {
if (isBlank(childInstruction)) {
return null;
}
// redirect instructions are already absolute
if (childInstruction instanceof RedirectInstruction) {
return childInstruction;
}
instruction.child = childInstruction;
return instruction;
});
}
if (candidate instanceof RedirectMatch) {
var instruction =
this.generate(candidate.redirectTo, ancestorInstructions.concat([null]));
return new RedirectInstruction(instruction.component, instruction.child,
instruction.auxInstruction, candidate.specificity);
}
}));
if ((isBlank(parsedUrl) || parsedUrl.path == '') && possibleMatches.length == 0) {
return PromiseWrapper.resolve(this.generateDefault(parentComponent));
}
return PromiseWrapper.all(matchPromises).then(mostSpecific);
}
private _auxRoutesToUnresolved(auxRoutes: Url[],
parentInstructions: Instruction[]): {[key: string]: Instruction} {
var unresolvedAuxInstructions: {[key: string]: Instruction} = {};
auxRoutes.forEach((auxUrl: Url) => {
unresolvedAuxInstructions[auxUrl.path] = new UnresolvedInstruction(
() => { return this._recognize(auxUrl, parentInstructions, true); });
});
return unresolvedAuxInstructions;
}
/**
* Given a normalized list with component names and params like: `['user', {id: 3 }]`
* generates a url with a leading slash relative to the provided `parentComponent`.
*
* If the optional param `_aux` is `true`, then we generate starting at an auxiliary
* route boundary.
*/
generate(linkParams: any[], ancestorInstructions: Instruction[], _aux = false): Instruction {
var params = splitAndFlattenLinkParams(linkParams);
var prevInstruction;
// The first segment should be either '.' (generate from parent) or '' (generate from root).
// When we normalize above, we strip all the slashes, './' becomes '.' and '/' becomes ''.
if (ListWrapper.first(params) == '') {
params.shift();
prevInstruction = ListWrapper.first(ancestorInstructions);
ancestorInstructions = [];
} else {
prevInstruction = ancestorInstructions.length > 0 ? ancestorInstructions.pop() : null;
if (ListWrapper.first(params) == '.') {
params.shift();
} else if (ListWrapper.first(params) == '..') {
while (ListWrapper.first(params) == '..') {
if (ancestorInstructions.length <= 0) {
throw new BaseException(
`Link "${ListWrapper.toJSON(linkParams)}" has too many "../" segments.`);
}
prevInstruction = ancestorInstructions.pop();
params = ListWrapper.slice(params, 1);
}
// we're on to implicit child/sibling route
} else {
// we must only peak at the link param, and not consume it
let routeName = ListWrapper.first(params);
let parentComponentType = this._rootComponent;
let grandparentComponentType = null;
if (ancestorInstructions.length > 1) {
let parentComponentInstruction = ancestorInstructions[ancestorInstructions.length - 1];
let grandComponentInstruction = ancestorInstructions[ancestorInstructions.length - 2];
parentComponentType = parentComponentInstruction.component.componentType;
grandparentComponentType = grandComponentInstruction.component.componentType;
} else if (ancestorInstructions.length == 1) {
parentComponentType = ancestorInstructions[0].component.componentType;
grandparentComponentType = this._rootComponent;
}
// For a link with no leading `./`, `/`, or `../`, we look for a sibling and child.
// If both exist, we throw. Otherwise, we prefer whichever exists.
var childRouteExists = this.hasRoute(routeName, parentComponentType);
var parentRouteExists = isPresent(grandparentComponentType) &&
this.hasRoute(routeName, grandparentComponentType);
if (parentRouteExists && childRouteExists) {
let msg =
`Link "${ListWrapper.toJSON(linkParams)}" is ambiguous, use "./" or "../" to disambiguate.`;
throw new BaseException(msg);
}
if (parentRouteExists) {
prevInstruction = ancestorInstructions.pop();
}
}
}
if (params[params.length - 1] == '') {
params.pop();
}
if (params.length > 0 && params[0] == '') {
params.shift();
}
if (params.length < 1) {
let msg = `Link "${ListWrapper.toJSON(linkParams)}" must include a route name.`;
throw new BaseException(msg);
}
var generatedInstruction =
this._generate(params, ancestorInstructions, prevInstruction, _aux, linkParams);
// we don't clone the first (root) element
for (var i = ancestorInstructions.length - 1; i >= 0; i--) {
let ancestorInstruction = ancestorInstructions[i];
if (isBlank(ancestorInstruction)) {
break;
}
generatedInstruction = ancestorInstruction.replaceChild(generatedInstruction);
}
return generatedInstruction;
}
/*
* Internal helper that does not make any assertions about the beginning of the link DSL.
* `ancestorInstructions` are parents that will be cloned.
* `prevInstruction` is the existing instruction that would be replaced, but which might have
* aux routes that need to be cloned.
*/
private _generate(linkParams: any[], ancestorInstructions: Instruction[],
prevInstruction: Instruction, _aux = false, _originalLink: any[]): Instruction {
let parentComponentType = this._rootComponent;
let componentInstruction = null;
let auxInstructions: {[key: string]: Instruction} = {};
let parentInstruction: Instruction = ListWrapper.last(ancestorInstructions);
if (isPresent(parentInstruction) && isPresent(parentInstruction.component)) {
parentComponentType = parentInstruction.component.componentType;
}
if (linkParams.length == 0) {
let defaultInstruction = this.generateDefault(parentComponentType);
if (isBlank(defaultInstruction)) {
throw new BaseException(
`Link "${ListWrapper.toJSON(_originalLink)}" does not resolve to a terminal instruction.`);
}
return defaultInstruction;
}
// for non-aux routes, we want to reuse the predecessor's existing primary and aux routes
// and only override routes for which the given link DSL provides
if (isPresent(prevInstruction) && !_aux) {
auxInstructions = StringMapWrapper.merge(prevInstruction.auxInstruction, auxInstructions);
componentInstruction = prevInstruction.component;
}
var rules = this._rules.get(parentComponentType);
if (isBlank(rules)) {
throw new BaseException(
`Component "${getTypeNameForDebugging(parentComponentType)}" has no route config.`);
}
let linkParamIndex = 0;
let routeParams: {[key: string]: any} = {};
// first, recognize the primary route if one is provided
if (linkParamIndex < linkParams.length && isString(linkParams[linkParamIndex])) {
let routeName = linkParams[linkParamIndex];
if (routeName == '' || routeName == '.' || routeName == '..') {
throw new BaseException(`"${routeName}/" is only allowed at the beginning of a link DSL.`);
}
linkParamIndex += 1;
if (linkParamIndex < linkParams.length) {
let linkParam = linkParams[linkParamIndex];
if (isStringMap(linkParam) && !isArray(linkParam)) {
routeParams = linkParam;
linkParamIndex += 1;
}
}
var routeRecognizer = (_aux ? rules.auxRulesByName : rules.rulesByName).get(routeName);
if (isBlank(routeRecognizer)) {
throw new BaseException(
`Component "${getTypeNameForDebugging(parentComponentType)}" has no route named "${routeName}".`);
}
// Create an "unresolved instruction" for async routes
// we'll figure out the rest of the route when we resolve the instruction and
// perform a navigation
if (isBlank(routeRecognizer.handler.componentType)) {
var generatedUrl = routeRecognizer.generateComponentPathValues(routeParams);
return new UnresolvedInstruction(() => {
return routeRecognizer.handler.resolveComponentType().then((_) => {
return this._generate(linkParams, ancestorInstructions, prevInstruction, _aux,
_originalLink);
});
}, generatedUrl.urlPath, convertUrlParamsToArray(generatedUrl.urlParams));
}
componentInstruction = _aux ? rules.generateAuxiliary(routeName, routeParams) :
rules.generate(routeName, routeParams);
}
// Next, recognize auxiliary instructions.
// If we have an ancestor instruction, we preserve whatever aux routes are active from it.
while (linkParamIndex < linkParams.length && isArray(linkParams[linkParamIndex])) {
let auxParentInstruction = [parentInstruction];
let auxInstruction = this._generate(linkParams[linkParamIndex], auxParentInstruction, null,
true, _originalLink);
// TODO: this will not work for aux routes with parameters or multiple segments
auxInstructions[auxInstruction.component.urlPath] = auxInstruction;
linkParamIndex += 1;
}
var instruction = new ResolvedInstruction(componentInstruction, null, auxInstructions);
// If the component is sync, we can generate resolved child route instructions
// If not, we'll resolve the instructions at navigation time
if (isPresent(componentInstruction) && isPresent(componentInstruction.componentType)) {
let childInstruction: Instruction = null;
if (componentInstruction.terminal) {
if (linkParamIndex >= linkParams.length) {
// TODO: throw that there are extra link params beyond the terminal component
}
} else {
let childAncestorComponents = ancestorInstructions.concat([instruction]);
let remainingLinkParams = linkParams.slice(linkParamIndex);
childInstruction = this._generate(remainingLinkParams, childAncestorComponents, null, false,
_originalLink);
}
instruction.child = childInstruction;
}
return instruction;
}
public hasRoute(name: string, parentComponent: any): boolean {
var rules = this._rules.get(parentComponent);
if (isBlank(rules)) {
return false;
}
return rules.hasRoute(name);
}
public generateDefault(componentCursor: Type): Instruction {
if (isBlank(componentCursor)) {
return null;
}
var rules = this._rules.get(componentCursor);
if (isBlank(rules) || isBlank(rules.defaultRule)) {
return null;
}
var defaultChild = null;
if (isPresent(rules.defaultRule.handler.componentType)) {
var componentInstruction = rules.defaultRule.generate({});
if (!rules.defaultRule.terminal) {
defaultChild = this.generateDefault(rules.defaultRule.handler.componentType);
}
return new DefaultInstruction(componentInstruction, defaultChild);
}
return new UnresolvedInstruction(() => {
return rules.defaultRule.handler.resolveComponentType().then(
(_) => this.generateDefault(componentCursor));
});
}
}
/*
* Given: ['/a/b', {c: 2}]
* Returns: ['', 'a', 'b', {c: 2}]
*/
function splitAndFlattenLinkParams(linkParams: any[]) {
var accumulation = [];
linkParams.forEach(function(item: any) {
if (isString(item)) {
var strItem: string = <string>item;
accumulation = accumulation.concat(strItem.split('/'));
} else {
accumulation.push(item);
}
});
return accumulation;
}
/*
* Given a list of instructions, returns the most specific instruction
*/
function mostSpecific(instructions: Instruction[]): Instruction {
instructions = instructions.filter((instruction) => isPresent(instruction));
if (instructions.length == 0) {
return null;
}
if (instructions.length == 1) {
return instructions[0];
}
var first = instructions[0];
var rest = instructions.slice(1);
return rest.reduce((instruction: Instruction, contender: Instruction) => {
if (compareSpecificityStrings(contender.specificity, instruction.specificity) == -1) {
return contender;
}
return instruction;
}, first);
}
/*
* Expects strings to be in the form of "[0-2]+"
* Returns -1 if string A should be sorted above string B, 1 if it should be sorted after,
* or 0 if they are the same.
*/
function compareSpecificityStrings(a: string, b: string): number {
var l = Math.min(a.length, b.length);
for (var i = 0; i < l; i += 1) {
var ai = StringWrapper.charCodeAt(a, i);
var bi = StringWrapper.charCodeAt(b, i);
var difference = bi - ai;
if (difference != 0) {
return difference;
}
}
return a.length - b.length;
}
function assertTerminalComponent(component, path) {
if (!isType(component)) {
return;
}
var annotations = reflector.annotations(component);
if (isPresent(annotations)) {
for (var i = 0; i < annotations.length; i++) {
var annotation = annotations[i];
if (annotation instanceof RouteConfig) {
throw new BaseException(
`Child routes are not allowed for "${path}". Use "..." on the parent's route path.`);
}
}
}
}