@curi/router
Version:
A JavaScript router that doesn't care how you render
874 lines (801 loc) • 30.1 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = global || self, factory(global.Curi = {}));
}(this, function (exports) { 'use strict';
function pathname(route, params) {
return route.methods.pathname(params);
}
function isAsyncRoute(route) {
return typeof route.methods.resolve !== "undefined";
}
function isExternalRedirect(redirect) {
return "externalURL" in redirect;
}
function isRedirectLocation(redirect) {
return "url" in redirect;
}
function finishResponse(route, match, resolvedResults, router, external) {
var _a = resolvedResults || {}, _b = _a.resolved, resolved = _b === void 0 ? null : _b, _c = _a.error, error = _c === void 0 ? null : _c;
var response = {
data: undefined,
body: undefined,
meta: undefined
};
for (var key in match) {
response[key] = match[key];
}
if (!route.methods.respond) {
return response;
}
var results = route.methods.respond({
resolved: resolved,
error: error,
match: match,
external: external
});
if (!results) {
{
console.warn("\"" + match.name + "\"'s response function did not return anything. Did you forget to include a return statement?");
}
return response;
}
{
var validProperties_1 = {
meta: true,
body: true,
data: true,
redirect: true
};
Object.keys(results).forEach(function (property) {
if (!validProperties_1.hasOwnProperty(property)) {
console.warn("\"" + property + "\" is not a valid response property. The valid properties are:\n\n " + Object.keys(validProperties_1).join(", "));
}
});
}
response["meta"] = results["meta"];
response["body"] = results["body"];
response["data"] = results["data"];
if (results["redirect"]) {
response["redirect"] = createRedirect(results["redirect"], router);
}
return response;
}
function createRedirect(redirect, router) {
if (isExternalRedirect(redirect)) {
return redirect;
}
var name = redirect.name, params = redirect.params, query = redirect.query, hash = redirect.hash, state = redirect.state;
var url = isRedirectLocation(redirect)
? redirect.url
: router.url({ name: name, params: params, query: query, hash: hash });
return {
name: name,
params: params,
query: query,
hash: hash,
state: state,
url: url
};
}
function createRouter(historyConstructor, routes, options) {
if (options === void 0) { options = {}; }
var latestResponse;
var latestNavigation;
var history = historyConstructor(function (pendingNav) {
var navigation = {
action: pendingNav.action,
previous: latestResponse
};
var matched = routes.match(pendingNav.location);
if (!matched) {
{
console.warn("The current location (" + pendingNav.location.pathname + ") has no matching route, " +
'so a response could not be emitted. A catch-all route ({ path: "(.*)" }) ' +
"can be used to match locations with no other matching route.");
}
pendingNav.finish();
finishAndResetNavCallbacks();
return;
}
var route = matched.route, match = matched.match;
if (!isAsyncRoute(route)) {
finalizeResponseAndEmit(route, match, pendingNav, navigation, null);
}
else {
announceAsyncNav();
route.methods
.resolve(match, options.external)
.then(function (resolved) { return ({ resolved: resolved, error: null }); }, function (error) { return ({ error: error, resolved: null }); })
.then(function (resolved) {
if (pendingNav.cancelled) {
return;
}
finalizeResponseAndEmit(route, match, pendingNav, navigation, resolved);
});
}
}, options.history || {});
function finalizeResponseAndEmit(route, match, pending, navigation, resolved) {
asyncNavComplete();
pending.finish();
var response = finishResponse(route, match, resolved, router, options.external);
finishAndResetNavCallbacks();
emitImmediate(response, navigation);
}
var _a = options.invisibleRedirects, invisibleRedirects = _a === void 0 ? false : _a;
function emitImmediate(response, navigation) {
if (!response.redirect ||
!invisibleRedirects ||
isExternalRedirect(response.redirect)) {
latestResponse = response;
latestNavigation = navigation;
var emit = { response: response, navigation: navigation, router: router };
callObservers(emit);
callOneTimersAndSideEffects(emit);
}
if (response.redirect !== undefined &&
!isExternalRedirect(response.redirect)) {
history.navigate(response.redirect, "replace");
}
}
function callObservers(emitted) {
observers.forEach(function (fn) {
fn(emitted);
});
}
function callOneTimersAndSideEffects(emitted) {
oneTimers.splice(0).forEach(function (fn) {
fn(emitted);
});
if (options.sideEffects) {
options.sideEffects.forEach(function (fn) {
fn(emitted);
});
}
}
/* router.observer & router.once */
var observers = [];
var oneTimers = [];
function observe(fn, options) {
var _a = (options || {}).initial, initial = _a === void 0 ? true : _a;
observers.push(fn);
if (latestResponse && initial) {
fn({
response: latestResponse,
navigation: latestNavigation,
router: router
});
}
return function () {
observers = observers.filter(function (obs) {
return obs !== fn;
});
};
}
function once(fn, options) {
var _a = (options || {}).initial, initial = _a === void 0 ? true : _a;
if (latestResponse && initial) {
fn({
response: latestResponse,
navigation: latestNavigation,
router: router
});
}
else {
oneTimers.push(fn);
}
}
/* router.url */
function url(details) {
var name = details.name, params = details.params, hash = details.hash, query = details.query;
var pathname$1;
if (name) {
var route = router.route(name);
if (route) {
pathname$1 = pathname(route, params);
}
}
return history.url({ pathname: pathname$1, hash: hash, query: query });
}
/* router.navigate */
var cancelCallback;
var finishCallback;
function navigate(details) {
cancelAndResetNavCallbacks();
var url = details.url, state = details.state, method = details.method;
history.navigate({ url: url, state: state }, method);
if (details.cancelled || details.finished) {
cancelCallback = details.cancelled;
finishCallback = details.finished;
return resetCallbacks;
}
}
function cancelAndResetNavCallbacks() {
if (cancelCallback) {
cancelCallback();
}
resetCallbacks();
}
function finishAndResetNavCallbacks() {
if (finishCallback) {
finishCallback();
}
resetCallbacks();
}
function resetCallbacks() {
cancelCallback = undefined;
finishCallback = undefined;
}
/* router.cancel */
var cancelWith;
var asyncNavNotifiers = [];
function cancel(fn) {
asyncNavNotifiers.push(fn);
return function () {
asyncNavNotifiers = asyncNavNotifiers.filter(function (can) {
return can !== fn;
});
};
}
// let any async navigation listeners (observers from router.cancel)
// know that there is an asynchronous navigation happening
function announceAsyncNav() {
if (asyncNavNotifiers.length && cancelWith === undefined) {
cancelWith = function () {
history.cancel();
asyncNavComplete();
cancelAndResetNavCallbacks();
};
asyncNavNotifiers.forEach(function (fn) {
fn(cancelWith);
});
}
}
function asyncNavComplete() {
if (cancelWith) {
cancelWith = undefined;
asyncNavNotifiers.forEach(function (fn) {
fn();
});
}
}
var router = {
route: routes.route,
history: history,
external: options.external,
observe: observe,
once: once,
cancel: cancel,
url: url,
navigate: navigate,
current: function () {
return {
response: latestResponse,
navigation: latestNavigation
};
},
destroy: function () {
history.destroy();
}
};
history.current();
return router;
}
/**
* Expose `pathToRegexp`.
*/
var pathToRegexp_1 = pathToRegexp;
var parse_1 = parse;
var compile_1 = compile;
var tokensToFunction_1 = tokensToFunction;
var tokensToRegExp_1 = tokensToRegExp;
/**
* Default configs.
*/
var DEFAULT_DELIMITER = '/';
var DEFAULT_DELIMITERS = './';
/**
* The main path matching regexp utility.
*
* @type {RegExp}
*/
var PATH_REGEXP = new RegExp([
// Match escaped characters that would otherwise appear in future matches.
// This allows the user to escape special characters that won't transform.
'(\\\\.)',
// Match Express-style parameters and un-named parameters with a prefix
// and optional suffixes. Matches appear as:
//
// ":test(\\d+)?" => ["test", "\d+", undefined, "?"]
// "(\\d+)" => [undefined, undefined, "\d+", undefined]
'(?:\\:(\\w+)(?:\\(((?:\\\\.|[^\\\\()])+)\\))?|\\(((?:\\\\.|[^\\\\()])+)\\))([+*?])?'
].join('|'), 'g');
/**
* Parse a string for the raw tokens.
*
* @param {string} str
* @param {Object=} options
* @return {!Array}
*/
function parse (str, options) {
var tokens = [];
var key = 0;
var index = 0;
var path = '';
var defaultDelimiter = (options && options.delimiter) || DEFAULT_DELIMITER;
var delimiters = (options && options.delimiters) || DEFAULT_DELIMITERS;
var pathEscaped = false;
var res;
while ((res = PATH_REGEXP.exec(str)) !== null) {
var m = res[0];
var escaped = res[1];
var offset = res.index;
path += str.slice(index, offset);
index = offset + m.length;
// Ignore already escaped sequences.
if (escaped) {
path += escaped[1];
pathEscaped = true;
continue
}
var prev = '';
var next = str[index];
var name = res[2];
var capture = res[3];
var group = res[4];
var modifier = res[5];
if (!pathEscaped && path.length) {
var k = path.length - 1;
if (delimiters.indexOf(path[k]) > -1) {
prev = path[k];
path = path.slice(0, k);
}
}
// Push the current path onto the tokens.
if (path) {
tokens.push(path);
path = '';
pathEscaped = false;
}
var partial = prev !== '' && next !== undefined && next !== prev;
var repeat = modifier === '+' || modifier === '*';
var optional = modifier === '?' || modifier === '*';
var delimiter = prev || defaultDelimiter;
var pattern = capture || group;
tokens.push({
name: name || key++,
prefix: prev,
delimiter: delimiter,
optional: optional,
repeat: repeat,
partial: partial,
pattern: pattern ? escapeGroup(pattern) : '[^' + escapeString(delimiter) + ']+?'
});
}
// Push any remaining characters.
if (path || index < str.length) {
tokens.push(path + str.substr(index));
}
return tokens
}
/**
* Compile a string to a template function for the path.
*
* @param {string} str
* @param {Object=} options
* @return {!function(Object=, Object=)}
*/
function compile (str, options) {
return tokensToFunction(parse(str, options))
}
/**
* Expose a method for transforming tokens into the path function.
*/
function tokensToFunction (tokens) {
// Compile all the tokens into regexps.
var matches = new Array(tokens.length);
// Compile all the patterns before compilation.
for (var i = 0; i < tokens.length; i++) {
if (typeof tokens[i] === 'object') {
matches[i] = new RegExp('^(?:' + tokens[i].pattern + ')$');
}
}
return function (data, options) {
var path = '';
var encode = (options && options.encode) || encodeURIComponent;
for (var i = 0; i < tokens.length; i++) {
var token = tokens[i];
if (typeof token === 'string') {
path += token;
continue
}
var value = data ? data[token.name] : undefined;
var segment;
if (Array.isArray(value)) {
if (!token.repeat) {
throw new TypeError('Expected "' + token.name + '" to not repeat, but got array')
}
if (value.length === 0) {
if (token.optional) continue
throw new TypeError('Expected "' + token.name + '" to not be empty')
}
for (var j = 0; j < value.length; j++) {
segment = encode(value[j], token);
if (!matches[i].test(segment)) {
throw new TypeError('Expected all "' + token.name + '" to match "' + token.pattern + '"')
}
path += (j === 0 ? token.prefix : token.delimiter) + segment;
}
continue
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
segment = encode(String(value), token);
if (!matches[i].test(segment)) {
throw new TypeError('Expected "' + token.name + '" to match "' + token.pattern + '", but got "' + segment + '"')
}
path += token.prefix + segment;
continue
}
if (token.optional) {
// Prepend partial segment prefixes.
if (token.partial) path += token.prefix;
continue
}
throw new TypeError('Expected "' + token.name + '" to be ' + (token.repeat ? 'an array' : 'a string'))
}
return path
}
}
/**
* Escape a regular expression string.
*
* @param {string} str
* @return {string}
*/
function escapeString (str) {
return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, '\\$1')
}
/**
* Escape the capturing group by escaping special characters and meaning.
*
* @param {string} group
* @return {string}
*/
function escapeGroup (group) {
return group.replace(/([=!:$/()])/g, '\\$1')
}
/**
* Get the flags for a regexp from the options.
*
* @param {Object} options
* @return {string}
*/
function flags (options) {
return options && options.sensitive ? '' : 'i'
}
/**
* Pull out keys from a regexp.
*
* @param {!RegExp} path
* @param {Array=} keys
* @return {!RegExp}
*/
function regexpToRegexp (path, keys) {
if (!keys) return path
// Use a negative lookahead to match only capturing groups.
var groups = path.source.match(/\((?!\?)/g);
if (groups) {
for (var i = 0; i < groups.length; i++) {
keys.push({
name: i,
prefix: null,
delimiter: null,
optional: false,
repeat: false,
partial: false,
pattern: null
});
}
}
return path
}
/**
* Transform an array into a regexp.
*
* @param {!Array} path
* @param {Array=} keys
* @param {Object=} options
* @return {!RegExp}
*/
function arrayToRegexp (path, keys, options) {
var parts = [];
for (var i = 0; i < path.length; i++) {
parts.push(pathToRegexp(path[i], keys, options).source);
}
return new RegExp('(?:' + parts.join('|') + ')', flags(options))
}
/**
* Create a path regexp from string input.
*
* @param {string} path
* @param {Array=} keys
* @param {Object=} options
* @return {!RegExp}
*/
function stringToRegexp (path, keys, options) {
return tokensToRegExp(parse(path, options), keys, options)
}
/**
* Expose a function for taking tokens and returning a RegExp.
*
* @param {!Array} tokens
* @param {Array=} keys
* @param {Object=} options
* @return {!RegExp}
*/
function tokensToRegExp (tokens, keys, options) {
options = options || {};
var strict = options.strict;
var start = options.start !== false;
var end = options.end !== false;
var delimiter = escapeString(options.delimiter || DEFAULT_DELIMITER);
var delimiters = options.delimiters || DEFAULT_DELIMITERS;
var endsWith = [].concat(options.endsWith || []).map(escapeString).concat('$').join('|');
var route = start ? '^' : '';
var isEndDelimited = tokens.length === 0;
// Iterate over the tokens and create our regexp string.
for (var i = 0; i < tokens.length; i++) {
var token = tokens[i];
if (typeof token === 'string') {
route += escapeString(token);
isEndDelimited = i === tokens.length - 1 && delimiters.indexOf(token[token.length - 1]) > -1;
} else {
var capture = token.repeat
? '(?:' + token.pattern + ')(?:' + escapeString(token.delimiter) + '(?:' + token.pattern + '))*'
: token.pattern;
if (keys) keys.push(token);
if (token.optional) {
if (token.partial) {
route += escapeString(token.prefix) + '(' + capture + ')?';
} else {
route += '(?:' + escapeString(token.prefix) + '(' + capture + '))?';
}
} else {
route += escapeString(token.prefix) + '(' + capture + ')';
}
}
}
if (end) {
if (!strict) route += '(?:' + delimiter + ')?';
route += endsWith === '$' ? '$' : '(?=' + endsWith + ')';
} else {
if (!strict) route += '(?:' + delimiter + '(?=' + endsWith + '))?';
if (!isEndDelimited) route += '(?=' + delimiter + '|' + endsWith + ')';
}
return new RegExp(route, flags(options))
}
/**
* Normalize the given path string, returning a regular expression.
*
* An empty array can be passed in for the keys, which will hold the
* placeholder key descriptions. For example, using `/user/:id`, `keys` will
* contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`.
*
* @param {(string|RegExp|Array)} path
* @param {Array=} keys
* @param {Object=} options
* @return {!RegExp}
*/
function pathToRegexp (path, keys, options) {
if (path instanceof RegExp) {
return regexpToRegexp(path, keys)
}
if (Array.isArray(path)) {
return arrayToRegexp(/** @type {!Array} */ (path), keys, options)
}
return stringToRegexp(/** @type {string} */ (path), keys, options)
}
pathToRegexp_1.parse = parse_1;
pathToRegexp_1.compile = compile_1;
pathToRegexp_1.tokensToFunction = tokensToFunction_1;
pathToRegexp_1.tokensToRegExp = tokensToRegExp_1;
var withLeadingSlash = function (path) {
return path.charAt(0) === "/" ? path : "/" + path;
};
var withTrailingSlash = function (path) {
return path.charAt(path.length - 1) === "/" ? path : path + "/";
};
var join = function (beginning, end) {
return withTrailingSlash(beginning) + end;
};
function createRoute(props, map, parent) {
if (parent === void 0) { parent = {
path: "",
keys: []
}; }
{
if (props.name in map) {
throw new Error("Multiple routes have the name \"" + props.name + "\". Route names must be unique.");
}
if (props.path.charAt(0) === "/") {
throw new Error("Route paths cannot start with a forward slash (/). (Received \"" + props.path + "\")");
}
}
var fullPath = withLeadingSlash(join(parent.path, props.path));
var _a = props.pathOptions || {}, _b = _a.match, matchOptions = _b === void 0 ? {} : _b, _c = _a.compile, compileOptions = _c === void 0 ? {} : _c;
// end must be false for routes with children, but we want to track its original value
var exact = matchOptions.end == null || matchOptions.end;
if (props.children && props.children.length) {
matchOptions.end = false;
}
var keys = [];
var re = pathToRegexp_1(withLeadingSlash(props.path), keys, matchOptions);
var keyNames = keys.map(function (key) { return key.name; });
if (parent.keys.length) {
keyNames = parent.keys.concat(keyNames);
}
var childRoutes = [];
var children = [];
if (props.children && props.children.length) {
childRoutes = props.children.map(function (child) {
return createRoute(child, map, {
path: fullPath,
keys: keyNames
});
});
children = childRoutes.map(function (child) { return child.public; });
}
var compiled = pathToRegexp_1.compile(fullPath);
var route = {
public: {
name: props.name,
keys: keyNames,
parent: undefined,
children: children,
methods: {
resolve: props.resolve,
respond: props.respond,
pathname: function (params) {
return compiled(params, compileOptions);
}
},
extra: props.extra
},
matching: {
re: re,
keys: keys,
exact: exact,
parsers: props.params || {},
children: childRoutes
}
};
map[props.name] = route.public;
if (childRoutes.length) {
childRoutes.forEach(function (child) {
child.public.parent = route.public;
});
}
return route;
}
function matchLocation(location, routes) {
for (var i = 0, len = routes.length; i < len; i++) {
var routeMatches = matchRoute(routes[i], location.pathname);
if (routeMatches.length) {
return createMatch(routeMatches, location);
}
}
}
function matchRoute(route, pathname) {
var _a = route.matching, re = _a.re, children = _a.children, exact = _a.exact;
var regExpMatch = re.exec(pathname);
if (!regExpMatch) {
return [];
}
var matchedSegment = regExpMatch[0], parsed = regExpMatch.slice(1);
var matches = [{ route: route, parsed: parsed }];
var remainder = pathname.slice(matchedSegment.length);
if (!children.length || remainder === "") {
return matches;
}
// match that ends with a strips it from the remainder
var fullSegments = withLeadingSlash(remainder);
for (var i = 0, length_1 = children.length; i < length_1; i++) {
var matched = matchRoute(children[i], fullSegments);
if (matched.length) {
return matches.concat(matched);
}
}
return exact ? [] : matches;
}
function createMatch(routeMatches, location) {
var route = routeMatches[routeMatches.length - 1].route.public;
return {
route: route,
match: {
location: location,
name: route.name,
params: routeMatches.reduce(function (params, _a) {
var route = _a.route, parsed = _a.parsed;
parsed.forEach(function (param, index) {
var name = route.matching.keys[index].name;
var fn = route.matching.parsers[name] || decodeURIComponent;
params[name] = fn(param);
});
return params;
}, {})
}
};
}
function prepareRoutes(routes) {
var mappedRoutes = {};
var prepared = routes.map(function (route) { return createRoute(route, mappedRoutes); });
return {
match: function (location) {
return matchLocation(location, prepared);
},
route: function (name) {
if (!(name in mappedRoutes)) {
console.warn("Attempting to use route \"" + name + "\", but no route with that name exists.");
}
return mappedRoutes[name];
}
};
}
function announce(fmt, mode) {
if (mode === void 0) { mode = "assertive"; }
var announcer = document.createElement("div");
announcer.setAttribute("aria-live", mode);
// https://hugogiraudel.com/2016/10/13/css-hide-and-seek/
announcer.setAttribute("style", [
"border: 0 !important;",
"clip: rect(1px, 1px, 1px, 1px) !important;",
"-webkit-clip-path: inset(50%) !important;",
"clip-path: inset(50%) !important;",
"height: 1px !important;",
"overflow: hidden !important;",
"padding: 0 !important;",
"position: absolute !important;",
"width: 1px !important;",
"white-space: nowrap !important;",
"top: 0;"
].join(" "));
document.body.appendChild(announcer);
return function (emitted) {
announcer.textContent = fmt(emitted);
};
}
function scroll() {
return function (_a) {
var response = _a.response, navigation = _a.navigation;
if (navigation.action === "pop") {
return;
}
// wait until after the re-render to scroll
setTimeout(function () {
var hash = response.location.hash;
if (hash !== "") {
var element = document.getElementById(hash);
if (element && element.scrollIntoView) {
element.scrollIntoView();
return;
}
}
// if there is no hash, no element matching the hash,
// or the browser doesn't support, we will just scroll
// to the top of the page
window.scrollTo(0, 0);
}, 0);
};
}
function title(callback) {
return function (emitted) {
document.title = callback(emitted);
};
}
exports.announce = announce;
exports.createRouter = createRouter;
exports.prepareRoutes = prepareRoutes;
exports.scroll = scroll;
exports.title = title;
Object.defineProperty(exports, '__esModule', { value: true });
}));