UNPKG

@curi/router

Version:

A JavaScript router that doesn't care how you render

874 lines (801 loc) 30.1 kB
(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 }); }));