ui-router
Version:
State-based routing for Javascript
461 lines (403 loc) • 18.9 kB
text/typescript
/** @module url */ /** for typedoc */
import {
map, defaults, extend, inherit, identity,
unnest, tail, forEach, find, omit, pairs, allTrueR
} from "../common/common";
import {prop, propEq } from "../common/hof";
import {isArray, isString} from "../common/predicates";
import {Param, paramTypes} from "../params/module";
import {isDefined} from "../common/predicates";
import {DefType} from "../params/param";
import {unnestR} from "../common/common";
import {arrayTuples} from "../common/common";
interface params {
$$validates: (params: string) => Array<string>;
}
function quoteRegExp(string: any, param?: any) {
let surroundPattern = ['', ''], result = string.replace(/[\\\[\]\^$*+?.()|{}]/g, "\\$&");
if (!param) return result;
switch (param.squash) {
case false:
surroundPattern = ['(', ')' + (param.isOptional ? '?' : '')]; break;
case true:
result = result.replace(/\/$/, '');
surroundPattern = ['(?:\/(', ')|\/)?'];
break;
default:
surroundPattern = [`(${param.squash}|`, ')?']; break;
}
return result + surroundPattern[0] + param.type.pattern.source + surroundPattern[1];
}
const memoizeTo = (obj, prop, fn) => obj[prop] = obj[prop] || fn();
/**
* @ngdoc object
* @name ui.router.util.type:UrlMatcher
*
* @description
* Matches URLs against patterns and extracts named parameters from the path or the search
* part of the URL. A URL pattern consists of a path pattern, optionally followed by '?' and a list
* of search parameters. Multiple search parameter names are separated by '&'. Search parameters
* do not influence whether or not a URL is matched, but their values are passed through into
* the matched parameters returned by {@link ui.router.util.type:UrlMatcher#methods_exec exec}.
*
* Path parameter placeholders can be specified using simple colon/catch-all syntax or curly brace
* syntax, which optionally allows a regular expression for the parameter to be specified:
*
* * `':'` name - colon placeholder
* * `'*'` name - catch-all placeholder
* * `'{' name '}'` - curly placeholder
* * `'{' name ':' regexp|type '}'` - curly placeholder with regexp or type name. Should the
* regexp itself contain curly braces, they must be in matched pairs or escaped with a backslash.
*
* Parameter names may contain only word characters (latin letters, digits, and underscore) and
* must be unique within the pattern (across both path and search parameters). For colon
* placeholders or curly placeholders without an explicit regexp, a path parameter matches any
* number of characters other than '/'. For catch-all placeholders the path parameter matches
* any number of characters.
*
* Examples:
*
* * `'/hello/'` - Matches only if the path is exactly '/hello/'. There is no special treatment for
* trailing slashes, and patterns have to match the entire path, not just a prefix.
* * `'/user/:id'` - Matches '/user/bob' or '/user/1234!!!' or even '/user/' but not '/user' or
* '/user/bob/details'. The second path segment will be captured as the parameter 'id'.
* * `'/user/{id}'` - Same as the previous example, but using curly brace syntax.
* * `'/user/{id:[^/]*}'` - Same as the previous example.
* * `'/user/{id:[0-9a-fA-F]{1,8}}'` - Similar to the previous example, but only matches if the id
* parameter consists of 1 to 8 hex digits.
* * `'/files/{path:.*}'` - Matches any URL starting with '/files/' and captures the rest of the
* path into the parameter 'path'.
* * `'/files/*path'` - ditto.
* * `'/calendar/{start:date}'` - Matches "/calendar/2014-11-12" (because the pattern defined
* in the built-in `date` Type matches `2014-11-12`) and provides a Date object in $stateParams.start
*
* @param {string} pattern The pattern to compile into a matcher.
* @param {Object} config A configuration object hash
* * `caseInsensitive` - `true` if URL matching should be case insensitive, otherwise `false`, the default value (for backward compatibility) is `false`.
* * `strict` - `false` if matching against a URL with a trailing slash should be treated as equivalent to a URL without a trailing slash, the default value is `true`.
*
* @property {string} prefix A static prefix of this pattern. The matcher guarantees that any
* URL matching this matcher (i.e. any string for which {@link ui.router.util.type:UrlMatcher#methods_exec exec()} returns
* non-null) will start with this prefix.
*
* @property {string} pattern The pattern that was passed into the constructor
*
* @returns {Object} New `UrlMatcher` object
*/
export class UrlMatcher {
static nameValidator: RegExp = /^\w+([-.]+\w+)*(?:\[\])?$/;
private _cache: { path: UrlMatcher[], pattern?: RegExp } = { path: [], pattern: null };
private _children: UrlMatcher[] = [];
private _params: Param[] = [];
private _segments: string[] = [];
private _compiled: string[] = [];
public prefix: string;
constructor(public pattern: string, public config: any) {
this.config = defaults(this.config, {
params: {},
strict: true,
caseInsensitive: false,
paramMap: identity
});
// Find all placeholders and create a compiled pattern, using either classic or curly syntax:
// '*' name
// ':' name
// '{' name '}'
// '{' name ':' regexp '}'
// The regular expression is somewhat complicated due to the need to allow curly braces
// inside the regular expression. The placeholder regexp breaks down as follows:
// ([:*])([\w\[\]]+) - classic placeholder ($1 / $2) (search version has - for snake-case)
// \{([\w\[\]]+)(?:\:\s*( ... ))?\} - curly brace placeholder ($3) with optional regexp/type ... ($4) (search version has - for snake-case
// (?: ... | ... | ... )+ - the regexp consists of any number of atoms, an atom being either
// [^{}\\]+ - anything other than curly braces or backslash
// \\. - a backslash escape
// \{(?:[^{}\\]+|\\.)*\} - a matched set of curly braces containing other atoms
let placeholder = /([:*])([\w\[\]]+)|\{([\w\[\]]+)(?:\:\s*((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g,
searchPlaceholder = /([:]?)([\w\[\].-]+)|\{([\w\[\].-]+)(?:\:\s*((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g,
last = 0, m, patterns = [];
const checkParamErrors = (id) => {
if (!UrlMatcher.nameValidator.test(id)) throw new Error(`Invalid parameter name '${id}' in pattern '${pattern}'`);
if (find(this._params, propEq('id', id))) throw new Error(`Duplicate parameter name '${id}' in pattern '${pattern}'`);
};
// Split into static segments separated by path parameter placeholders.
// The number of segments is always 1 more than the number of parameters.
const matchDetails = (m, isSearch) => {
// IE[78] returns '' for unmatched groups instead of null
let id = m[2] || m[3], regexp = isSearch ? m[4] : m[4] || (m[1] === '*' ? '.*' : null);
return {
id,
regexp,
cfg: this.config.params[id],
segment: pattern.substring(last, m.index),
type: !regexp ? null : paramTypes.type(regexp || "string") || inherit(paramTypes.type("string"), {
pattern: new RegExp(regexp, this.config.caseInsensitive ? 'i' : undefined)
})
};
}
let p, segment;
while ((m = placeholder.exec(pattern))) {
p = matchDetails(m, false);
if (p.segment.indexOf('?') >= 0) break; // we're into the search part
checkParamErrors(p.id);
this._params.push(Param.fromPath(p.id, p.type, this.config.paramMap(p.cfg, false)));
this._segments.push(p.segment);
patterns.push([p.segment, tail(this._params)]);
last = placeholder.lastIndex;
}
segment = pattern.substring(last);
// Find any search parameter names and remove them from the last segment
let i = segment.indexOf('?');
if (i >= 0) {
let search = segment.substring(i);
segment = segment.substring(0, i);
if (search.length > 0) {
last = 0;
while ((m = searchPlaceholder.exec(search))) {
p = matchDetails(m, true);
checkParamErrors(p.id);
this._params.push(Param.fromSearch(p.id, p.type, this.config.paramMap(p.cfg, true)));
last = placeholder.lastIndex;
// check if ?&
}
}
}
this._segments.push(segment);
extend(this, {
_compiled: patterns.map(pattern => quoteRegExp.apply(null, pattern)).concat(quoteRegExp(segment)),
prefix: this._segments[0]
});
Object.freeze(this);
}
/**
* @ngdoc function
* @name ui.router.util.type:UrlMatcher#append
* @methodOf ui.router.util.type:UrlMatcher
*
* @description
* @TODO
*
* @example
* @TODO
*
* @param {UrlMatcher} url A `UrlMatcher` instance to append as a child of the current `UrlMatcher`.
*/
append(url: UrlMatcher): UrlMatcher {
this._children.push(url);
forEach(url._cache, (val, key) => url._cache[key] = isArray(val) ? [] : null);
url._cache.path = this._cache.path.concat(this);
return url;
}
isRoot(): boolean {
return this._cache.path.length === 0;
}
toString(): string {
return this.pattern;
}
/**
* @ngdoc function
* @name ui.router.util.type:UrlMatcher#exec
* @methodOf ui.router.util.type:UrlMatcher
*
* @description
* Tests the specified path against this matcher, and returns an object containing the captured
* parameter values, or null if the path does not match. The returned object contains the values
* of any search parameters that are mentioned in the pattern, but their value may be null if
* they are not present in `search`. This means that search parameters are always treated
* as optional.
*
* @example
* <pre>
* new UrlMatcher('/user/{id}?q&r').exec('/user/bob', {
* x: '1', q: 'hello'
* });
* // returns { id: 'bob', q: 'hello', r: null }
* </pre>
*
* @param {string} path The URL path to match, e.g. `$location.path()`.
* @param {Object} search URL search parameters, e.g. `$location.search()`.
* @param {string} hash URL hash e.g. `$location.hash()`.
* @param {Object} options
* @returns {Object} The captured parameter values.
*/
exec(path: string, search: any = {}, hash?: string, options: any = {}) {
let match = memoizeTo(this._cache, 'pattern', () => {
return new RegExp([
'^',
unnest(this._cache.path.concat(this).map(prop('_compiled'))).join(''),
this.config.strict === false ? '\/?' : '',
'$'
].join(''), this.config.caseInsensitive ? 'i' : undefined);
}).exec(path);
if (!match) return null;
//options = defaults(options, { isolate: false });
let allParams: Param[] = this.parameters(),
pathParams: Param[] = allParams.filter(param => !param.isSearch()),
searchParams: Param[] = allParams.filter(param => param.isSearch()),
nPathSegments = this._cache.path.concat(this).map(urlm => urlm._segments.length - 1).reduce((a, x) => a + x),
values = {};
if (nPathSegments !== match.length - 1)
throw new Error(`Unbalanced capture group in route '${this.pattern}'`);
function decodePathArray(string: string) {
const reverseString = (str: string) => str.split("").reverse().join("");
const unquoteDashes = (str: string) => str.replace(/\\-/g, "-");
let split = reverseString(string).split(/-(?!\\)/);
let allReversed = map(split, reverseString);
return map(allReversed, unquoteDashes).reverse();
}
for (let i = 0; i < nPathSegments; i++) {
let param: Param = pathParams[i];
let value: (any|any[]) = match[i + 1];
// if the param value matches a pre-replace pair, replace the value before decoding.
for (let j = 0; j < param.replace.length; j++) {
if (param.replace[j].from === value) value = param.replace[j].to;
}
if (value && param.array === true) value = decodePathArray(value);
if (isDefined(value)) value = param.type.decode(value);
values[param.id] = param.value(value);
}
forEach(searchParams, param => {
let value = search[param.id];
for (let j = 0; j < param.replace.length; j++) {
if (param.replace[j].from === value) value = param.replace[j].to;
}
if (isDefined(value)) value = param.type.decode(value);
values[param.id] = param.value(value);
});
if (hash) values["#"] = hash;
return values;
}
/**
* @ngdoc function
* @name ui.router.util.type:UrlMatcher#parameters
* @methodOf ui.router.util.type:UrlMatcher
*
* @description
* Returns the names of all path and search parameters of this pattern in order of appearance.
*
* @returns {Array.<Param>} An array of [[Param]] objects. Must be treated as read-only. If the
* pattern has no parameters, an empty array is returned.
*/
parameters(opts: any = {}): Param[] {
if (opts.inherit === false) return this._params;
return unnest(this._cache.path.concat(this).map(prop('_params')));
}
parameter(id: string, opts: any = {}): Param {
const parent = tail(this._cache.path);
return (
find(this._params, propEq('id', id)) ||
(opts.inherit !== false && parent && parent.parameter(id)) ||
null
);
}
/**
* @ngdoc function
* @name ui.router.util.type:UrlMatcher#validates
* @methodOf ui.router.util.type:UrlMatcher
*
* @description
* Checks an object hash of parameters to validate their correctness according to the parameter
* types of this `UrlMatcher`.
*
* @param {Object} params The object hash of parameters to validate.
* @returns {boolean} Returns `true` if `params` validates, otherwise `false`.
*/
validates(params): boolean {
const validParamVal = (param: Param, val) => !param || param.validates(val);
return pairs(params || {}).map(([key, val]) => validParamVal(this.parameter(key), val)).reduce(allTrueR, true);
}
/**
* @ngdoc function
* @name ui.router.util.type:UrlMatcher#format
* @methodOf ui.router.util.type:UrlMatcher
*
* @description
* Creates a URL that matches this pattern by substituting the specified values
* for the path and search parameters. Null values for path parameters are
* treated as empty strings.
*
* @example
* <pre>
* new UrlMatcher('/user/{id}?q').format({ id:'bob', q:'yes' });
* // returns '/user/bob?q=yes'
* </pre>
*
* @param {Object} values the values to substitute for the parameters in this pattern.
* @returns {string} the formatted URL (path and optionally search part).
*/
format(values = {}) {
if (!this.validates(values)) return null;
// Build the full path of UrlMatchers (including all parent UrlMatchers)
let urlMatchers = this._cache.path.slice().concat(this);
// Extract all the static segments and Params into an ordered array
let pathSegmentsAndParams: Array<string|Param> =
urlMatchers.map(UrlMatcher.pathSegmentsAndParams).reduce(unnestR, []);
// Extract the query params into a separate array
let queryParams: Array<Param> =
urlMatchers.map(UrlMatcher.queryParams).reduce(unnestR, []);
/**
* Given a Param,
* Applies the parameter value, then returns details about it
*/
function getDetails(param: Param): ParamDetails {
// Normalize to typed value
let value = param.value(values[param.id]);
let isDefaultValue = param.isDefaultValue(value);
// Check if we're in squash mode for the parameter
let squash = isDefaultValue ? param.squash : false;
// Allow the Parameter's Type to encode the value
let encoded = param.type.encode(value);
return { param, value, isDefaultValue, squash, encoded };
}
// Build up the path-portion from the list of static segments and parameters
let pathString = pathSegmentsAndParams.reduce((acc: string, x: string|Param) => {
// The element is a static segment (a raw string); just append it
if (isString(x)) return acc + x;
// Otherwise, it's a Param. Fetch details about the parameter value
let {squash, encoded, param} = getDetails(<Param> x);
// If squash is === true, try to remove a slash from the path
if (squash === true) return (acc.match(/\/$/)) ? acc.slice(0, -1) : acc;
// If squash is a string, use the string for the param value
if (isString(squash)) return acc + squash;
if (squash !== false) return acc; // ?
if (encoded == null) return acc;
// If this parameter value is an array, encode the value using encodeDashes
if (isArray(encoded)) return acc + map(<string[]> encoded, UrlMatcher.encodeDashes).join("-");
// If the parameter type is "raw", then do not encodeURIComponent
if (param.type.raw) return acc + encoded;
// Encode the value
return acc + encodeURIComponent(<string> encoded);
}, "");
// Build the query string by applying parameter values (array or regular)
// then mapping to key=value, then flattening and joining using "&"
let queryString = queryParams.map((param: Param) => {
let {squash, encoded, isDefaultValue} = getDetails(param);
if (encoded == null || (isDefaultValue && squash !== false)) return;
if (!isArray(encoded)) encoded = [<string> encoded];
if (encoded.length === 0) return;
if (!param.type.raw) encoded = map(<string[]> encoded, encodeURIComponent);
return (<string[]> encoded).map(val => `${param.id}=${val}`);
}).filter(identity).reduce(unnestR, []).join("&");
// Concat the pathstring with the queryString (if exists) and the hashString (if exists)
return pathString + (queryString ? `?${queryString}` : "") + (values["#"] ? "#" + values["#"] : "");
}
static encodeDashes(str) { // Replace dashes with encoded "\-"
return encodeURIComponent(str).replace(/-/g, c => `%5C%${c.charCodeAt(0).toString(16).toUpperCase()}`);
}
/** Given a matcher, return an array with the matcher's path segments and path params, in order */
static pathSegmentsAndParams(matcher: UrlMatcher) {
let staticSegments = matcher._segments;
let pathParams = matcher._params.filter(p => p.location === DefType.PATH);
return arrayTuples(staticSegments, pathParams.concat(undefined)).reduce(unnestR, []).filter(x => x !== "" && isDefined(x));
}
/** Given a matcher, return an array with the matcher's query params */
static queryParams(matcher: UrlMatcher): Param[] {
return matcher._params.filter(p => p.location === DefType.SEARCH);
}
}
interface ParamDetails {
param: Param;
value: any;
isDefaultValue: boolean;
squash: (boolean|string);
encoded: (string|string[]);
}