UNPKG

vue-router

Version:
1,369 lines (1,353 loc) 115 kB
/*! * vue-router v4.5.0 * (c) 2024 Eduardo San Martin Morote * @license MIT */ 'use strict'; var vue = require('vue'); const isBrowser = typeof document !== 'undefined'; /** * Allows differentiating lazy components from functional components and vue-class-component * @internal * * @param component */ function isRouteComponent(component) { return (typeof component === 'object' || 'displayName' in component || 'props' in component || '__vccOpts' in component); } function isESModule(obj) { return (obj.__esModule || obj[Symbol.toStringTag] === 'Module' || // support CF with dynamic imports that do not // add the Module string tag (obj.default && isRouteComponent(obj.default))); } const assign = Object.assign; function applyToParams(fn, params) { const newParams = {}; for (const key in params) { const value = params[key]; newParams[key] = isArray(value) ? value.map(fn) : fn(value); } return newParams; } const noop = () => { }; /** * Typesafe alternative to Array.isArray * https://github.com/microsoft/TypeScript/pull/48228 */ const isArray = Array.isArray; /** * Encoding Rules (␣ = Space) * - Path: ␣ " < > # ? { } * - Query: ␣ " < > # & = * - Hash: ␣ " < > ` * * On top of that, the RFC3986 (https://tools.ietf.org/html/rfc3986#section-2.2) * defines some extra characters to be encoded. Most browsers do not encode them * in encodeURI https://github.com/whatwg/url/issues/369, so it may be safer to * also encode `!'()*`. Leaving un-encoded only ASCII alphanumeric(`a-zA-Z0-9`) * plus `-._~`. This extra safety should be applied to query by patching the * string returned by encodeURIComponent encodeURI also encodes `[\]^`. `\` * should be encoded to avoid ambiguity. Browsers (IE, FF, C) transform a `\` * into a `/` if directly typed in. The _backtick_ (`````) should also be * encoded everywhere because some browsers like FF encode it when directly * written while others don't. Safari and IE don't encode ``"<>{}``` in hash. */ // const EXTRA_RESERVED_RE = /[!'()*]/g // const encodeReservedReplacer = (c: string) => '%' + c.charCodeAt(0).toString(16) const HASH_RE = /#/g; // %23 const AMPERSAND_RE = /&/g; // %26 const SLASH_RE = /\//g; // %2F const EQUAL_RE = /=/g; // %3D const IM_RE = /\?/g; // %3F const PLUS_RE = /\+/g; // %2B /** * NOTE: It's not clear to me if we should encode the + symbol in queries, it * seems to be less flexible than not doing so and I can't find out the legacy * systems requiring this for regular requests like text/html. In the standard, * the encoding of the plus character is only mentioned for * application/x-www-form-urlencoded * (https://url.spec.whatwg.org/#urlencoded-parsing) and most browsers seems lo * leave the plus character as is in queries. To be more flexible, we allow the * plus character on the query, but it can also be manually encoded by the user. * * Resources: * - https://url.spec.whatwg.org/#urlencoded-parsing * - https://stackoverflow.com/questions/1634271/url-encoding-the-space-character-or-20 */ const ENC_BRACKET_OPEN_RE = /%5B/g; // [ const ENC_BRACKET_CLOSE_RE = /%5D/g; // ] const ENC_CARET_RE = /%5E/g; // ^ const ENC_BACKTICK_RE = /%60/g; // ` const ENC_CURLY_OPEN_RE = /%7B/g; // { const ENC_PIPE_RE = /%7C/g; // | const ENC_CURLY_CLOSE_RE = /%7D/g; // } const ENC_SPACE_RE = /%20/g; // } /** * Encode characters that need to be encoded on the path, search and hash * sections of the URL. * * @internal * @param text - string to encode * @returns encoded string */ function commonEncode(text) { return encodeURI('' + text) .replace(ENC_PIPE_RE, '|') .replace(ENC_BRACKET_OPEN_RE, '[') .replace(ENC_BRACKET_CLOSE_RE, ']'); } /** * Encode characters that need to be encoded on the hash section of the URL. * * @param text - string to encode * @returns encoded string */ function encodeHash(text) { return commonEncode(text) .replace(ENC_CURLY_OPEN_RE, '{') .replace(ENC_CURLY_CLOSE_RE, '}') .replace(ENC_CARET_RE, '^'); } /** * Encode characters that need to be encoded query values on the query * section of the URL. * * @param text - string to encode * @returns encoded string */ function encodeQueryValue(text) { return (commonEncode(text) // Encode the space as +, encode the + to differentiate it from the space .replace(PLUS_RE, '%2B') .replace(ENC_SPACE_RE, '+') .replace(HASH_RE, '%23') .replace(AMPERSAND_RE, '%26') .replace(ENC_BACKTICK_RE, '`') .replace(ENC_CURLY_OPEN_RE, '{') .replace(ENC_CURLY_CLOSE_RE, '}') .replace(ENC_CARET_RE, '^')); } /** * Like `encodeQueryValue` but also encodes the `=` character. * * @param text - string to encode */ function encodeQueryKey(text) { return encodeQueryValue(text).replace(EQUAL_RE, '%3D'); } /** * Encode characters that need to be encoded on the path section of the URL. * * @param text - string to encode * @returns encoded string */ function encodePath(text) { return commonEncode(text).replace(HASH_RE, '%23').replace(IM_RE, '%3F'); } /** * Encode characters that need to be encoded on the path section of the URL as a * param. This function encodes everything {@link encodePath} does plus the * slash (`/`) character. If `text` is `null` or `undefined`, returns an empty * string instead. * * @param text - string to encode * @returns encoded string */ function encodeParam(text) { return text == null ? '' : encodePath(text).replace(SLASH_RE, '%2F'); } /** * Decode text using `decodeURIComponent`. Returns the original text if it * fails. * * @param text - string to decode * @returns decoded string */ function decode(text) { try { return decodeURIComponent('' + text); } catch (err) { } return '' + text; } const TRAILING_SLASH_RE = /\/$/; const removeTrailingSlash = (path) => path.replace(TRAILING_SLASH_RE, ''); /** * Transforms a URI into a normalized history location * * @param parseQuery * @param location - URI to normalize * @param currentLocation - current absolute location. Allows resolving relative * paths. Must start with `/`. Defaults to `/` * @returns a normalized history location */ function parseURL(parseQuery, location, currentLocation = '/') { let path, query = {}, searchString = '', hash = ''; // Could use URL and URLSearchParams but IE 11 doesn't support it // TODO: move to new URL() const hashPos = location.indexOf('#'); let searchPos = location.indexOf('?'); // the hash appears before the search, so it's not part of the search string if (hashPos < searchPos && hashPos >= 0) { searchPos = -1; } if (searchPos > -1) { path = location.slice(0, searchPos); searchString = location.slice(searchPos + 1, hashPos > -1 ? hashPos : location.length); query = parseQuery(searchString); } if (hashPos > -1) { path = path || location.slice(0, hashPos); // keep the # character hash = location.slice(hashPos, location.length); } // no search and no query path = resolveRelativePath(path != null ? path : location, currentLocation); // empty path means a relative query or hash `?foo=f`, `#thing` return { fullPath: path + (searchString && '?') + searchString + hash, path, query, hash: decode(hash), }; } /** * Stringifies a URL object * * @param stringifyQuery * @param location */ function stringifyURL(stringifyQuery, location) { const query = location.query ? stringifyQuery(location.query) : ''; return location.path + (query && '?') + query + (location.hash || ''); } /** * Strips off the base from the beginning of a location.pathname in a non-case-sensitive way. * * @param pathname - location.pathname * @param base - base to strip off */ function stripBase(pathname, base) { // no base or base is not found at the beginning if (!base || !pathname.toLowerCase().startsWith(base.toLowerCase())) return pathname; return pathname.slice(base.length) || '/'; } /** * Checks if two RouteLocation are equal. This means that both locations are * pointing towards the same {@link RouteRecord} and that all `params`, `query` * parameters and `hash` are the same * * @param stringifyQuery - A function that takes a query object of type LocationQueryRaw and returns a string representation of it. * @param a - first {@link RouteLocation} * @param b - second {@link RouteLocation} */ function isSameRouteLocation(stringifyQuery, a, b) { const aLastIndex = a.matched.length - 1; const bLastIndex = b.matched.length - 1; return (aLastIndex > -1 && aLastIndex === bLastIndex && isSameRouteRecord(a.matched[aLastIndex], b.matched[bLastIndex]) && isSameRouteLocationParams(a.params, b.params) && stringifyQuery(a.query) === stringifyQuery(b.query) && a.hash === b.hash); } /** * Check if two `RouteRecords` are equal. Takes into account aliases: they are * considered equal to the `RouteRecord` they are aliasing. * * @param a - first {@link RouteRecord} * @param b - second {@link RouteRecord} */ function isSameRouteRecord(a, b) { // since the original record has an undefined value for aliasOf // but all aliases point to the original record, this will always compare // the original record return (a.aliasOf || a) === (b.aliasOf || b); } function isSameRouteLocationParams(a, b) { if (Object.keys(a).length !== Object.keys(b).length) return false; for (const key in a) { if (!isSameRouteLocationParamsValue(a[key], b[key])) return false; } return true; } function isSameRouteLocationParamsValue(a, b) { return isArray(a) ? isEquivalentArray(a, b) : isArray(b) ? isEquivalentArray(b, a) : a === b; } /** * Check if two arrays are the same or if an array with one single entry is the * same as another primitive value. Used to check query and parameters * * @param a - array of values * @param b - array of values or a single value */ function isEquivalentArray(a, b) { return isArray(b) ? a.length === b.length && a.every((value, i) => value === b[i]) : a.length === 1 && a[0] === b; } /** * Resolves a relative path that starts with `.`. * * @param to - path location we are resolving * @param from - currentLocation.path, should start with `/` */ function resolveRelativePath(to, from) { if (to.startsWith('/')) return to; if (!to) return from; const fromSegments = from.split('/'); const toSegments = to.split('/'); const lastToSegment = toSegments[toSegments.length - 1]; // make . and ./ the same (../ === .., ../../ === ../..) // this is the same behavior as new URL() if (lastToSegment === '..' || lastToSegment === '.') { toSegments.push(''); } let position = fromSegments.length - 1; let toPosition; let segment; for (toPosition = 0; toPosition < toSegments.length; toPosition++) { segment = toSegments[toPosition]; // we stay on the same position if (segment === '.') continue; // go up in the from array if (segment === '..') { // we can't go below zero, but we still need to increment toPosition if (position > 1) position--; // continue } // we reached a non-relative path, we stop here else break; } return (fromSegments.slice(0, position).join('/') + '/' + toSegments.slice(toPosition).join('/')); } /** * Initial route location where the router is. Can be used in navigation guards * to differentiate the initial navigation. * * @example * ```js * import { START_LOCATION } from 'vue-router' * * router.beforeEach((to, from) => { * if (from === START_LOCATION) { * // initial navigation * } * }) * ``` */ const START_LOCATION_NORMALIZED = { path: '/', // TODO: could we use a symbol in the future? name: undefined, params: {}, query: {}, hash: '', fullPath: '/', matched: [], meta: {}, redirectedFrom: undefined, }; var NavigationType; (function (NavigationType) { NavigationType["pop"] = "pop"; NavigationType["push"] = "push"; })(NavigationType || (NavigationType = {})); var NavigationDirection; (function (NavigationDirection) { NavigationDirection["back"] = "back"; NavigationDirection["forward"] = "forward"; NavigationDirection["unknown"] = ""; })(NavigationDirection || (NavigationDirection = {})); /** * Starting location for Histories */ const START = ''; // Generic utils /** * Normalizes a base by removing any trailing slash and reading the base tag if * present. * * @param base - base to normalize */ function normalizeBase(base) { if (!base) { if (isBrowser) { // respect <base> tag const baseEl = document.querySelector('base'); base = (baseEl && baseEl.getAttribute('href')) || '/'; // strip full URL origin base = base.replace(/^\w+:\/\/[^\/]+/, ''); } else { base = '/'; } } // ensure leading slash when it was removed by the regex above avoid leading // slash with hash because the file could be read from the disk like file:// // and the leading slash would cause problems if (base[0] !== '/' && base[0] !== '#') base = '/' + base; // remove the trailing slash so all other method can just do `base + fullPath` // to build an href return removeTrailingSlash(base); } // remove any character before the hash const BEFORE_HASH_RE = /^[^#]+#/; function createHref(base, location) { return base.replace(BEFORE_HASH_RE, '#') + location; } function getElementPosition(el, offset) { const docRect = document.documentElement.getBoundingClientRect(); const elRect = el.getBoundingClientRect(); return { behavior: offset.behavior, left: elRect.left - docRect.left - (offset.left || 0), top: elRect.top - docRect.top - (offset.top || 0), }; } const computeScrollPosition = () => ({ left: window.scrollX, top: window.scrollY, }); function scrollToPosition(position) { let scrollToOptions; if ('el' in position) { const positionEl = position.el; const isIdSelector = typeof positionEl === 'string' && positionEl.startsWith('#'); const el = typeof positionEl === 'string' ? isIdSelector ? document.getElementById(positionEl.slice(1)) : document.querySelector(positionEl) : positionEl; if (!el) { return; } scrollToOptions = getElementPosition(el, position); } else { scrollToOptions = position; } if ('scrollBehavior' in document.documentElement.style) window.scrollTo(scrollToOptions); else { window.scrollTo(scrollToOptions.left != null ? scrollToOptions.left : window.scrollX, scrollToOptions.top != null ? scrollToOptions.top : window.scrollY); } } function getScrollKey(path, delta) { const position = history.state ? history.state.position - delta : -1; return position + path; } const scrollPositions = new Map(); function saveScrollPosition(key, scrollPosition) { scrollPositions.set(key, scrollPosition); } function getSavedScrollPosition(key) { const scroll = scrollPositions.get(key); // consume it so it's not used again scrollPositions.delete(key); return scroll; } // TODO: RFC about how to save scroll position /** * ScrollBehavior instance used by the router to compute and restore the scroll * position when navigating. */ // export interface ScrollHandler<ScrollPositionEntry extends HistoryStateValue, ScrollPosition extends ScrollPositionEntry> { // // returns a scroll position that can be saved in history // compute(): ScrollPositionEntry // // can take an extended ScrollPositionEntry // scroll(position: ScrollPosition): void // } // export const scrollHandler: ScrollHandler<ScrollPosition> = { // compute: computeScroll, // scroll: scrollToPosition, // } let createBaseLocation = () => location.protocol + '//' + location.host; /** * Creates a normalized history location from a window.location object * @param base - The base path * @param location - The window.location object */ function createCurrentLocation(base, location) { const { pathname, search, hash } = location; // allows hash bases like #, /#, #/, #!, #!/, /#!/, or even /folder#end const hashPos = base.indexOf('#'); if (hashPos > -1) { let slicePos = hash.includes(base.slice(hashPos)) ? base.slice(hashPos).length : 1; let pathFromHash = hash.slice(slicePos); // prepend the starting slash to hash so the url starts with /# if (pathFromHash[0] !== '/') pathFromHash = '/' + pathFromHash; return stripBase(pathFromHash, ''); } const path = stripBase(pathname, base); return path + search + hash; } function useHistoryListeners(base, historyState, currentLocation, replace) { let listeners = []; let teardowns = []; // TODO: should it be a stack? a Dict. Check if the popstate listener // can trigger twice let pauseState = null; const popStateHandler = ({ state, }) => { const to = createCurrentLocation(base, location); const from = currentLocation.value; const fromState = historyState.value; let delta = 0; if (state) { currentLocation.value = to; historyState.value = state; // ignore the popstate and reset the pauseState if (pauseState && pauseState === from) { pauseState = null; return; } delta = fromState ? state.position - fromState.position : 0; } else { replace(to); } // Here we could also revert the navigation by calling history.go(-delta) // this listener will have to be adapted to not trigger again and to wait for the url // to be updated before triggering the listeners. Some kind of validation function would also // need to be passed to the listeners so the navigation can be accepted // call all listeners listeners.forEach(listener => { listener(currentLocation.value, from, { delta, type: NavigationType.pop, direction: delta ? delta > 0 ? NavigationDirection.forward : NavigationDirection.back : NavigationDirection.unknown, }); }); }; function pauseListeners() { pauseState = currentLocation.value; } function listen(callback) { // set up the listener and prepare teardown callbacks listeners.push(callback); const teardown = () => { const index = listeners.indexOf(callback); if (index > -1) listeners.splice(index, 1); }; teardowns.push(teardown); return teardown; } function beforeUnloadListener() { const { history } = window; if (!history.state) return; history.replaceState(assign({}, history.state, { scroll: computeScrollPosition() }), ''); } function destroy() { for (const teardown of teardowns) teardown(); teardowns = []; window.removeEventListener('popstate', popStateHandler); window.removeEventListener('beforeunload', beforeUnloadListener); } // set up the listeners and prepare teardown callbacks window.addEventListener('popstate', popStateHandler); // TODO: could we use 'pagehide' or 'visibilitychange' instead? // https://developer.chrome.com/blog/page-lifecycle-api/ window.addEventListener('beforeunload', beforeUnloadListener, { passive: true, }); return { pauseListeners, listen, destroy, }; } /** * Creates a state object */ function buildState(back, current, forward, replaced = false, computeScroll = false) { return { back, current, forward, replaced, position: window.history.length, scroll: computeScroll ? computeScrollPosition() : null, }; } function useHistoryStateNavigation(base) { const { history, location } = window; // private variables const currentLocation = { value: createCurrentLocation(base, location), }; const historyState = { value: history.state }; // build current history entry as this is a fresh navigation if (!historyState.value) { changeLocation(currentLocation.value, { back: null, current: currentLocation.value, forward: null, // the length is off by one, we need to decrease it position: history.length - 1, replaced: true, // don't add a scroll as the user may have an anchor, and we want // scrollBehavior to be triggered without a saved position scroll: null, }, true); } function changeLocation(to, state, replace) { /** * if a base tag is provided, and we are on a normal domain, we have to * respect the provided `base` attribute because pushState() will use it and * potentially erase anything before the `#` like at * https://github.com/vuejs/router/issues/685 where a base of * `/folder/#` but a base of `/` would erase the `/folder/` section. If * there is no host, the `<base>` tag makes no sense and if there isn't a * base tag we can just use everything after the `#`. */ const hashIndex = base.indexOf('#'); const url = hashIndex > -1 ? (location.host && document.querySelector('base') ? base : base.slice(hashIndex)) + to : createBaseLocation() + base + to; try { // BROWSER QUIRK // NOTE: Safari throws a SecurityError when calling this function 100 times in 30 seconds history[replace ? 'replaceState' : 'pushState'](state, '', url); historyState.value = state; } catch (err) { { console.error(err); } // Force the navigation, this also resets the call count location[replace ? 'replace' : 'assign'](url); } } function replace(to, data) { const state = assign({}, history.state, buildState(historyState.value.back, // keep back and forward entries but override current position to, historyState.value.forward, true), data, { position: historyState.value.position }); changeLocation(to, state, true); currentLocation.value = to; } function push(to, data) { // Add to current entry the information of where we are going // as well as saving the current position const currentState = assign({}, // use current history state to gracefully handle a wrong call to // history.replaceState // https://github.com/vuejs/router/issues/366 historyState.value, history.state, { forward: to, scroll: computeScrollPosition(), }); changeLocation(currentState.current, currentState, true); const state = assign({}, buildState(currentLocation.value, to, null), { position: currentState.position + 1 }, data); changeLocation(to, state, false); currentLocation.value = to; } return { location: currentLocation, state: historyState, push, replace, }; } /** * Creates an HTML5 history. Most common history for single page applications. * * @param base - */ function createWebHistory(base) { base = normalizeBase(base); const historyNavigation = useHistoryStateNavigation(base); const historyListeners = useHistoryListeners(base, historyNavigation.state, historyNavigation.location, historyNavigation.replace); function go(delta, triggerListeners = true) { if (!triggerListeners) historyListeners.pauseListeners(); history.go(delta); } const routerHistory = assign({ // it's overridden right after location: '', base, go, createHref: createHref.bind(null, base), }, historyNavigation, historyListeners); Object.defineProperty(routerHistory, 'location', { enumerable: true, get: () => historyNavigation.location.value, }); Object.defineProperty(routerHistory, 'state', { enumerable: true, get: () => historyNavigation.state.value, }); return routerHistory; } /** * Creates an in-memory based history. The main purpose of this history is to handle SSR. It starts in a special location that is nowhere. * It's up to the user to replace that location with the starter location by either calling `router.push` or `router.replace`. * * @param base - Base applied to all urls, defaults to '/' * @returns a history object that can be passed to the router constructor */ function createMemoryHistory(base = '') { let listeners = []; let queue = [START]; let position = 0; base = normalizeBase(base); function setLocation(location) { position++; if (position !== queue.length) { // we are in the middle, we remove everything from here in the queue queue.splice(position); } queue.push(location); } function triggerListeners(to, from, { direction, delta }) { const info = { direction, delta, type: NavigationType.pop, }; for (const callback of listeners) { callback(to, from, info); } } const routerHistory = { // rewritten by Object.defineProperty location: START, // TODO: should be kept in queue state: {}, base, createHref: createHref.bind(null, base), replace(to) { // remove current entry and decrement position queue.splice(position--, 1); setLocation(to); }, push(to, data) { setLocation(to); }, listen(callback) { listeners.push(callback); return () => { const index = listeners.indexOf(callback); if (index > -1) listeners.splice(index, 1); }; }, destroy() { listeners = []; queue = [START]; position = 0; }, go(delta, shouldTrigger = true) { const from = this.location; const direction = // we are considering delta === 0 going forward, but in abstract mode // using 0 for the delta doesn't make sense like it does in html5 where // it reloads the page delta < 0 ? NavigationDirection.back : NavigationDirection.forward; position = Math.max(0, Math.min(position + delta, queue.length - 1)); if (shouldTrigger) { triggerListeners(this.location, from, { direction, delta, }); } }, }; Object.defineProperty(routerHistory, 'location', { enumerable: true, get: () => queue[position], }); return routerHistory; } /** * Creates a hash history. Useful for web applications with no host (e.g. `file://`) or when configuring a server to * handle any URL is not possible. * * @param base - optional base to provide. Defaults to `location.pathname + location.search` If there is a `<base>` tag * in the `head`, its value will be ignored in favor of this parameter **but note it affects all the history.pushState() * calls**, meaning that if you use a `<base>` tag, it's `href` value **has to match this parameter** (ignoring anything * after the `#`). * * @example * ```js * // at https://example.com/folder * createWebHashHistory() // gives a url of `https://example.com/folder#` * createWebHashHistory('/folder/') // gives a url of `https://example.com/folder/#` * // if the `#` is provided in the base, it won't be added by `createWebHashHistory` * createWebHashHistory('/folder/#/app/') // gives a url of `https://example.com/folder/#/app/` * // you should avoid doing this because it changes the original url and breaks copying urls * createWebHashHistory('/other-folder/') // gives a url of `https://example.com/other-folder/#` * * // at file:///usr/etc/folder/index.html * // for locations with no `host`, the base is ignored * createWebHashHistory('/iAmIgnored') // gives a url of `file:///usr/etc/folder/index.html#` * ``` */ function createWebHashHistory(base) { // Make sure this implementation is fine in terms of encoding, specially for IE11 // for `file://`, directly use the pathname and ignore the base // location.pathname contains an initial `/` even at the root: `https://example.com` base = location.host ? base || location.pathname + location.search : ''; // allow the user to provide a `#` in the middle: `/base/#/app` if (!base.includes('#')) base += '#'; return createWebHistory(base); } function isRouteLocation(route) { return typeof route === 'string' || (route && typeof route === 'object'); } function isRouteName(name) { return typeof name === 'string' || typeof name === 'symbol'; } const NavigationFailureSymbol = Symbol(''); /** * Enumeration with all possible types for navigation failures. Can be passed to * {@link isNavigationFailure} to check for specific failures. */ exports.NavigationFailureType = void 0; (function (NavigationFailureType) { /** * An aborted navigation is a navigation that failed because a navigation * guard returned `false` or called `next(false)` */ NavigationFailureType[NavigationFailureType["aborted"] = 4] = "aborted"; /** * A cancelled navigation is a navigation that failed because a more recent * navigation finished started (not necessarily finished). */ NavigationFailureType[NavigationFailureType["cancelled"] = 8] = "cancelled"; /** * A duplicated navigation is a navigation that failed because it was * initiated while already being at the exact same location. */ NavigationFailureType[NavigationFailureType["duplicated"] = 16] = "duplicated"; })(exports.NavigationFailureType || (exports.NavigationFailureType = {})); // DEV only debug messages const ErrorTypeMessages = { [1 /* ErrorTypes.MATCHER_NOT_FOUND */]({ location, currentLocation }) { return `No match for\n ${JSON.stringify(location)}${currentLocation ? '\nwhile being at\n' + JSON.stringify(currentLocation) : ''}`; }, [2 /* ErrorTypes.NAVIGATION_GUARD_REDIRECT */]({ from, to, }) { return `Redirected from "${from.fullPath}" to "${stringifyRoute(to)}" via a navigation guard.`; }, [4 /* ErrorTypes.NAVIGATION_ABORTED */]({ from, to }) { return `Navigation aborted from "${from.fullPath}" to "${to.fullPath}" via a navigation guard.`; }, [8 /* ErrorTypes.NAVIGATION_CANCELLED */]({ from, to }) { return `Navigation cancelled from "${from.fullPath}" to "${to.fullPath}" with a new navigation.`; }, [16 /* ErrorTypes.NAVIGATION_DUPLICATED */]({ from, to }) { return `Avoided redundant navigation to current location: "${from.fullPath}".`; }, }; /** * Creates a typed NavigationFailure object. * @internal * @param type - NavigationFailureType * @param params - { from, to } */ function createRouterError(type, params) { // keep full error messages in cjs versions { return assign(new Error(ErrorTypeMessages[type](params)), { type, [NavigationFailureSymbol]: true, }, params); } } function isNavigationFailure(error, type) { return (error instanceof Error && NavigationFailureSymbol in error && (type == null || !!(error.type & type))); } const propertiesToLog = ['params', 'query', 'hash']; function stringifyRoute(to) { if (typeof to === 'string') return to; if (to.path != null) return to.path; const location = {}; for (const key of propertiesToLog) { if (key in to) location[key] = to[key]; } return JSON.stringify(location, null, 2); } // default pattern for a param: non-greedy everything but / const BASE_PARAM_PATTERN = '[^/]+?'; const BASE_PATH_PARSER_OPTIONS = { sensitive: false, strict: false, start: true, end: true, }; // Special Regex characters that must be escaped in static tokens const REGEX_CHARS_RE = /[.+*?^${}()[\]/\\]/g; /** * Creates a path parser from an array of Segments (a segment is an array of Tokens) * * @param segments - array of segments returned by tokenizePath * @param extraOptions - optional options for the regexp * @returns a PathParser */ function tokensToParser(segments, extraOptions) { const options = assign({}, BASE_PATH_PARSER_OPTIONS, extraOptions); // the amount of scores is the same as the length of segments except for the root segment "/" const score = []; // the regexp as a string let pattern = options.start ? '^' : ''; // extracted keys const keys = []; for (const segment of segments) { // the root segment needs special treatment const segmentScores = segment.length ? [] : [90 /* PathScore.Root */]; // allow trailing slash if (options.strict && !segment.length) pattern += '/'; for (let tokenIndex = 0; tokenIndex < segment.length; tokenIndex++) { const token = segment[tokenIndex]; // resets the score if we are inside a sub-segment /:a-other-:b let subSegmentScore = 40 /* PathScore.Segment */ + (options.sensitive ? 0.25 /* PathScore.BonusCaseSensitive */ : 0); if (token.type === 0 /* TokenType.Static */) { // prepend the slash if we are starting a new segment if (!tokenIndex) pattern += '/'; pattern += token.value.replace(REGEX_CHARS_RE, '\\$&'); subSegmentScore += 40 /* PathScore.Static */; } else if (token.type === 1 /* TokenType.Param */) { const { value, repeatable, optional, regexp } = token; keys.push({ name: value, repeatable, optional, }); const re = regexp ? regexp : BASE_PARAM_PATTERN; // the user provided a custom regexp /:id(\\d+) if (re !== BASE_PARAM_PATTERN) { subSegmentScore += 10 /* PathScore.BonusCustomRegExp */; // make sure the regexp is valid before using it try { new RegExp(`(${re})`); } catch (err) { throw new Error(`Invalid custom RegExp for param "${value}" (${re}): ` + err.message); } } // when we repeat we must take care of the repeating leading slash let subPattern = repeatable ? `((?:${re})(?:/(?:${re}))*)` : `(${re})`; // prepend the slash if we are starting a new segment if (!tokenIndex) subPattern = // avoid an optional / if there are more segments e.g. /:p?-static // or /:p?-:p2 optional && segment.length < 2 ? `(?:/${subPattern})` : '/' + subPattern; if (optional) subPattern += '?'; pattern += subPattern; subSegmentScore += 20 /* PathScore.Dynamic */; if (optional) subSegmentScore += -8 /* PathScore.BonusOptional */; if (repeatable) subSegmentScore += -20 /* PathScore.BonusRepeatable */; if (re === '.*') subSegmentScore += -50 /* PathScore.BonusWildcard */; } segmentScores.push(subSegmentScore); } // an empty array like /home/ -> [[{home}], []] // if (!segment.length) pattern += '/' score.push(segmentScores); } // only apply the strict bonus to the last score if (options.strict && options.end) { const i = score.length - 1; score[i][score[i].length - 1] += 0.7000000000000001 /* PathScore.BonusStrict */; } // TODO: dev only warn double trailing slash if (!options.strict) pattern += '/?'; if (options.end) pattern += '$'; // allow paths like /dynamic to only match dynamic or dynamic/... but not dynamic_something_else else if (options.strict && !pattern.endsWith('/')) pattern += '(?:/|$)'; const re = new RegExp(pattern, options.sensitive ? '' : 'i'); function parse(path) { const match = path.match(re); const params = {}; if (!match) return null; for (let i = 1; i < match.length; i++) { const value = match[i] || ''; const key = keys[i - 1]; params[key.name] = value && key.repeatable ? value.split('/') : value; } return params; } function stringify(params) { let path = ''; // for optional parameters to allow to be empty let avoidDuplicatedSlash = false; for (const segment of segments) { if (!avoidDuplicatedSlash || !path.endsWith('/')) path += '/'; avoidDuplicatedSlash = false; for (const token of segment) { if (token.type === 0 /* TokenType.Static */) { path += token.value; } else if (token.type === 1 /* TokenType.Param */) { const { value, repeatable, optional } = token; const param = value in params ? params[value] : ''; if (isArray(param) && !repeatable) { throw new Error(`Provided param "${value}" is an array but it is not repeatable (* or + modifiers)`); } const text = isArray(param) ? param.join('/') : param; if (!text) { if (optional) { // if we have more than one optional param like /:a?-static we don't need to care about the optional param if (segment.length < 2) { // remove the last slash as we could be at the end if (path.endsWith('/')) path = path.slice(0, -1); // do not append a slash on the next iteration else avoidDuplicatedSlash = true; } } else throw new Error(`Missing required param "${value}"`); } path += text; } } } // avoid empty path when we have multiple optional params return path || '/'; } return { re, score, keys, parse, stringify, }; } /** * Compares an array of numbers as used in PathParser.score and returns a * number. This function can be used to `sort` an array * * @param a - first array of numbers * @param b - second array of numbers * @returns 0 if both are equal, < 0 if a should be sorted first, > 0 if b * should be sorted first */ function compareScoreArray(a, b) { let i = 0; while (i < a.length && i < b.length) { const diff = b[i] - a[i]; // only keep going if diff === 0 if (diff) return diff; i++; } // if the last subsegment was Static, the shorter segments should be sorted first // otherwise sort the longest segment first if (a.length < b.length) { return a.length === 1 && a[0] === 40 /* PathScore.Static */ + 40 /* PathScore.Segment */ ? -1 : 1; } else if (a.length > b.length) { return b.length === 1 && b[0] === 40 /* PathScore.Static */ + 40 /* PathScore.Segment */ ? 1 : -1; } return 0; } /** * Compare function that can be used with `sort` to sort an array of PathParser * * @param a - first PathParser * @param b - second PathParser * @returns 0 if both are equal, < 0 if a should be sorted first, > 0 if b */ function comparePathParserScore(a, b) { let i = 0; const aScore = a.score; const bScore = b.score; while (i < aScore.length && i < bScore.length) { const comp = compareScoreArray(aScore[i], bScore[i]); // do not return if both are equal if (comp) return comp; i++; } if (Math.abs(bScore.length - aScore.length) === 1) { if (isLastScoreNegative(aScore)) return 1; if (isLastScoreNegative(bScore)) return -1; } // if a and b share the same score entries but b has more, sort b first return bScore.length - aScore.length; // this is the ternary version // return aScore.length < bScore.length // ? 1 // : aScore.length > bScore.length // ? -1 // : 0 } /** * This allows detecting splats at the end of a path: /home/:id(.*)* * * @param score - score to check * @returns true if the last entry is negative */ function isLastScoreNegative(score) { const last = score[score.length - 1]; return score.length > 0 && last[last.length - 1] < 0; } const ROOT_TOKEN = { type: 0 /* TokenType.Static */, value: '', }; const VALID_PARAM_RE = /[a-zA-Z0-9_]/; // After some profiling, the cache seems to be unnecessary because tokenizePath // (the slowest part of adding a route) is very fast // const tokenCache = new Map<string, Token[][]>() function tokenizePath(path) { if (!path) return [[]]; if (path === '/') return [[ROOT_TOKEN]]; if (!path.startsWith('/')) { throw new Error(`Invalid path "${path}"`); } // if (tokenCache.has(path)) return tokenCache.get(path)! function crash(message) { throw new Error(`ERR (${state})/"${buffer}": ${message}`); } let state = 0 /* TokenizerState.Static */; let previousState = state; const tokens = []; // the segment will always be valid because we get into the initial state // with the leading / let segment; function finalizeSegment() { if (segment) tokens.push(segment); segment = []; } // index on the path let i = 0; // char at index let char; // buffer of the value read let buffer = ''; // custom regexp for a param let customRe = ''; function consumeBuffer() { if (!buffer) return; if (state === 0 /* TokenizerState.Static */) { segment.push({ type: 0 /* TokenType.Static */, value: buffer, }); } else if (state === 1 /* TokenizerState.Param */ || state === 2 /* TokenizerState.ParamRegExp */ || state === 3 /* TokenizerState.ParamRegExpEnd */) { if (segment.length > 1 && (char === '*' || char === '+')) crash(`A repeatable param (${buffer}) must be alone in its segment. eg: '/:ids+.`); segment.push({ type: 1 /* TokenType.Param */, value: buffer, regexp: customRe, repeatable: char === '*' || char === '+', optional: char === '*' || char === '?', }); } else { crash('Invalid state to consume buffer'); } buffer = ''; } function addCharToBuffer() { buffer += char; } while (i < path.length) { char = path[i++]; if (char === '\\' && state !== 2 /* TokenizerState.ParamRegExp */) { previousState = state; state = 4 /* TokenizerState.EscapeNext */; continue; } switch (state) { case 0 /* TokenizerState.Static */: if (char === '/') { if (buffer) { consumeBuffer(); } finalizeSegment(); } else if (char === ':') { consumeBuffer(); state = 1 /* TokenizerState.Param */; } else { addCharToBuffer(); } break; case 4 /* TokenizerState.EscapeNext */: addCharToBuffer(); state = previousState; break; case 1 /* TokenizerState.Param */: if (char === '(') { state = 2 /* TokenizerState.ParamRegExp */; } else if (VALID_PARAM_RE.test(char)) { addCharToBuffer(); } else { consumeBuffer(); state = 0 /* TokenizerState.Static */; // go back one character if we were not modifying if (char !== '*' && char !== '?' && char !== '+') i--; } break; case 2 /* TokenizerState.ParamRegExp */: // TODO: is it worth handling nested regexp? like :p(?:prefix_([^/]+)_suffix) // it already works by escaping the closing ) // https://paths.esm.dev/?p=AAMeJbiAwQEcDKbAoAAkP60PG2R6QAvgNaA6AFACM2ABuQBB# // is this really something people need since you can also write // /prefix_:p()_suffix if (char === ')') { // handle the escaped ) if (customRe[customRe.length - 1] == '\\') customRe = customRe.slice(0, -1) + char; else state = 3 /* TokenizerState.ParamRegExpEnd */; } else { customRe += char; } break; case 3 /* TokenizerState.ParamRegExpEnd */: // same as finalizing a param consumeBuffer(); state = 0 /* TokenizerState.Static */; // go back one character if we were not modifying if (char !== '*' && char !== '?' && char !== '+') i--; customRe = ''; break; default: crash('Unknown state'); break; } } if (state === 2 /* TokenizerState.ParamRegExp */) crash(`Unfinished custom RegExp for param "${buffer}"`); consumeBuffer(); finalizeSegment(); // tokenCache.set(path, tokens) return tokens; } function createRouteRecordMatcher(record, parent, options) { const parser = tokensToParser(tokenizePath(record.path), options); const matcher = assign(parser, { record, parent, // these needs to be populated by the parent children: [], alias: [], }); if (parent) { // both are aliases or both are not aliases // we don't want to mix them because the order is used when // passing originalRecord in Matcher.addRoute if (!matcher.record.aliasOf === !parent.record.aliasOf) parent.children.push(matcher); } return matcher; } /** * Creates a Router Matcher. * * @internal * @param routes - array of initial routes * @param globalOptions - global route options */ function createRouterMatcher(routes, globalOptions) { // normalized ordered array of matchers const matchers = []; const matcherMap = new Map(); globalOptions = mergeOptions({ strict: false, end: true, sensitive: false }, globalOptions); function getRecordMatcher(name) { return matcherMap.get(name); } function addRoute(record, parent, originalRecord) { // used later on to remove by name const isRootAdd = !originalRecord; const mainNormalizedRecord = normalizeRouteRecord(record); // we might be the child of an alias mainNormalizedRecord.aliasOf = originalRecord && originalRecord.record; const options = mergeOptions(globalOptions, record); // generate an array of records to correctly handle aliases const normalizedRecords = [mainNormalizedRecord]; if ('alias' in record) { const aliases = typeof record.alias === 'string' ? [record.alias] : record.alias; for (const alias of aliases) { normalizedRecords.push( // we need to normalize again to ensure the `mods` property // being non enumerable normalizeRouteRecord(assign({}, mainNormalizedRecord, { // this allows us to hold a copy of the `component