UNPKG

rescript-relay-router

Version:
382 lines (374 loc) 13.5 kB
/** MIT License Copyright (c) React Training 2015-2019 Copyright (c) Remix Software 2020-2022 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ // RescriptRelayRouter note: // Inlined + modified from https://github.com/remix-run/react-router/blob/main/packages/router/router.ts /** * Matches the given routes to a location and returns the match data. * * @see https://reactrouter.com/utils/match-routes */ export function matchRoutes(routes, locationArg, basename = "/") { let location = typeof locationArg === "string" ? parsePath(locationArg) : locationArg; let pathname = stripBasename(location.pathname || "/", basename); if (pathname == null) { return null; } let branches = flattenRoutes(routes); rankRouteBranches(branches); let matches = null; for (let i = 0; matches == null && i < branches.length; ++i) { matches = matchRouteBranch( branches[i], // Incoming pathnames are generally encoded from either window.location // or from router.navigate, but we want to match against the unencoded // paths in the route definitions. Memory router locations won't be // encoded here but there also shouldn't be anything to decode so this // should be a safe operation. This avoids needing matchRoutes to be // history-aware. safelyDecodeURI(pathname) ); } return matches; } function flattenRoutes( routes, branches = [], parentsMeta = [], parentPath = "" ) { routes.forEach((route, index) => { let meta = { relativePath: route.path || "", caseSensitive: route.caseSensitive === true, childrenIndex: index, route, }; if (meta.relativePath.startsWith("/")) { invariant( meta.relativePath.startsWith(parentPath), `Absolute route path "${meta.relativePath}" nested under path ` + `"${parentPath}" is not valid. An absolute child route path ` + `must start with the combined path of all its parent routes.` ); meta.relativePath = meta.relativePath.slice(parentPath.length); } let path = joinPaths([parentPath, meta.relativePath]); let routesMeta = parentsMeta.concat(meta); // Add the children before adding this route to the array so we traverse the // route tree depth-first and child routes appear before their parents in // the "flattened" version. if (route.children && route.children.length > 0) { invariant( // Our types know better, but runtime JS may not! // @ts-expect-error route.index !== true, `Index routes must not have child routes. Please remove ` + `all child routes from route path "${path}".` ); flattenRoutes(route.children, branches, routesMeta, path); } // Routes without a path shouldn't ever match by themselves unless they are // index routes, so don't add them to the list of possible branches. if (route.path == null && !route.index) { return; } branches.push({ path, score: computeScore(path, route.index), routesMeta }); }); return branches; } function rankRouteBranches(branches) { branches.sort((a, b) => a.score !== b.score ? b.score - a.score // Higher score first : compareIndexes( a.routesMeta.map((meta) => meta.childrenIndex), b.routesMeta.map((meta) => meta.childrenIndex) ) ); } const paramRe = /^:\w+$/; const dynamicSegmentValue = 3; const regexSegmentValue = 7; const indexRouteValue = 2; const emptySegmentValue = 1; const staticSegmentValue = 10; const splatPenalty = -2; const isSplat = (s) => s === "*"; function computeScore(path, index) { let segments = path.split("/"); let initialScore = segments.length; if (segments.some(isSplat)) { initialScore += splatPenalty; } if (index) { initialScore += indexRouteValue; } return segments .filter((s) => !isSplat(s)) .reduce((score, segment) => { let scoreToAdd = 0; if (paramRe.test(segment)) { // Means regexp segment if (segment.endsWith(")")) { scoreToAdd = regexSegmentValue; } else { scoreToAdd = dynamicSegmentValue; } } else { if (segment === "") { scoreToAdd = emptySegmentValue; } else { scoreToAdd = staticSegmentValue; } } return score + scoreToAdd; }, initialScore); } function compareIndexes(a, b) { let siblings = a.length === b.length && a.slice(0, -1).every((n, i) => n === b[i]); return siblings ? // If two routes are siblings, we should try to match the earlier sibling // first. This allows people to have fine-grained control over the matching // behavior by simply putting routes with identical paths in the order they // want them tried. a[a.length - 1] - b[b.length - 1] : // Otherwise, it doesn't really make sense to rank non-siblings by index, // so they sort equally. 0; } function matchRouteBranch(branch, pathname) { let { routesMeta } = branch; let matchedParams = {}; let matchedPathname = "/"; let matches = []; for (let i = 0; i < routesMeta.length; ++i) { let meta = routesMeta[i]; let end = i === routesMeta.length - 1; let remainingPathname = matchedPathname === "/" ? pathname : pathname.slice(matchedPathname.length) || "/"; let match = matchPath( { path: meta.relativePath, caseSensitive: meta.caseSensitive, end }, remainingPathname ); if (!match) return null; Object.assign(matchedParams, match.params); let route = meta.route; matches.push({ // TODO: Can this as be avoided? params: matchedParams, pathname: joinPaths([matchedPathname, match.pathname]), pathnameBase: normalizePathname( joinPaths([matchedPathname, match.pathnameBase]) ), route, }); if (match.pathnameBase !== "/") { matchedPathname = joinPaths([matchedPathname, match.pathnameBase]); } } return matches; } /** * Returns a path with params interpolated. * * @see https://reactrouter.com/utils/generate-path */ export function generatePath(path, params = {}) { return path .replace(/:([\w(|)]+)/g, (_, key) => { let targetKey = key; if (key.endsWith(")")) { targetKey = key.split("(")[0]; } invariant(params[targetKey] != null, `Missing ":${targetKey}" param`); return params[targetKey]; }) .replace(/(\/?)\*/, (_, prefix, __, str) => { const star = "*"; if (params[star] == null) { // If no splat was provided, trim the trailing slash _unless_ it's // the entire path return str === "/*" ? "/" : ""; } // Apply the splat return `${prefix}${params[star]}`; }); } /** * Performs pattern matching on a URL pathname and returns information about * the match. * * @see https://reactrouter.com/utils/match-path */ export function matchPath(pattern, pathname) { if (typeof pattern === "string") { pattern = { path: pattern, caseSensitive: false, end: true }; } let [matcher, paramNames] = compilePath( pattern.path, pattern.caseSensitive, pattern.end ); let match = pathname.match(matcher); if (!match) return null; let matchedPathname = match[0]; let pathnameBase = matchedPathname.replace(/(.)\/+$/, "$1"); let captureGroups = match.slice(1); let params = paramNames.reduce((memo, paramName, index) => { // We need to compute the pathnameBase here using the raw splat value // instead of using params["*"] later because it will be decoded then if (paramName === "*") { let splatValue = captureGroups[index] || ""; pathnameBase = matchedPathname .slice(0, matchedPathname.length - splatValue.length) .replace(/(.)\/+$/, "$1"); } memo[paramName] = safelyDecodeURIComponent( captureGroups[index] || "", paramName ); return memo; }, {}); return { params, pathname: matchedPathname, pathnameBase, pattern, }; } function compilePath(path, caseSensitive = false, end = true) { warning( path === "*" || !path.endsWith("*") || path.endsWith("/*"), `Route path "${path}" will be treated as if it were ` + `"${path.replace(/\*$/, "/*")}" because the \`*\` character must ` + `always follow a \`/\` in the pattern. To get rid of this warning, ` + `please change the route path to "${path.replace(/\*$/, "/*")}".` ); let paramNames = []; let regexpSource = "^" + path .replace(/\/*\*?$/, "") // Ignore trailing / and /*, we'll handle it below .replace(/^\/*/, "/") // Make sure it has a leading / .replace(/[\\.*+^$?{}[\]]/g, "\\$&") // Escape special regex chars .replace(/:([\w(|)-]+)/g, (_, paramName) => { // Check if this is a regexp param. If so, special treatment. if (paramName.endsWith(")")) { let [pname, regexp] = paramName.slice(0, -1).split("("); paramNames.push(pname); return `(${regexp})`; } paramNames.push(paramName); return "([^\\/]+)"; }); if (path.endsWith("*")) { paramNames.push("*"); regexpSource += path === "*" || path === "/*" ? "(.*)$" // Already matched the initial /, just match the rest : "(?:\\/(.+)|\\/*)$"; // Don't include the / in params["*"] } else if (end) { // When matching to the end, ignore trailing slashes regexpSource += "\\/*$"; } else if (path !== "" && path !== "/") { // If our path is non-empty and contains anything beyond an initial slash, // then we have _some_ form of path in our regex so we should expect to // match only if we find the end of this path segment. Look for an optional // non-captured trailing slash (to match a portion of the URL) or the end // of the path (if we've matched to the end). We used to do this with a // word boundary but that gives false positives on routes like // /user-preferences since `-` counts as a word boundary. regexpSource += "(?:(?=\\/|$))"; } else { // Nothing to match for "" or "/" } let matcher = new RegExp(regexpSource, caseSensitive ? undefined : "i"); return [matcher, paramNames]; } function safelyDecodeURI(value) { try { return decodeURI(value); } catch (error) { warning( false, `The URL path "${value}" could not be decoded because it is is a ` + `malformed URL segment. This is probably due to a bad percent ` + `encoding (${error}).` ); return value; } } function safelyDecodeURIComponent(value, paramName) { try { return decodeURIComponent(value); } catch (error) { warning( false, `The value for the URL param "${paramName}" will not be decoded because` + ` the string "${value}" is a malformed URL segment. This is probably` + ` due to a bad percent encoding (${error}).` ); return value; } } /** * @private */ function stripBasename(pathname, basename) { if (basename === "/") return pathname; if (!pathname.toLowerCase().startsWith(basename.toLowerCase())) { return null; } // We want to leave trailing slash behavior in the user's control, so if they // specify a basename with a trailing slash, we should support it let startIndex = basename.endsWith("/") ? basename.length - 1 : basename.length; let nextChar = pathname.charAt(startIndex); if (nextChar && nextChar !== "/") { // pathname does not start with basename/ return null; } return pathname.slice(startIndex) || "/"; } function invariant(value, message) { if (value === false || value === null || typeof value === "undefined") { throw new Error(message); } } /** * @private */ function warning(cond, message) { if (!cond) { // eslint-disable-next-line no-console if (typeof console !== "undefined") console.warn(message); try { // Welcome to debugging React Router! // // This error is thrown as a convenience so you can more easily // find the source for a warning that appears in the console by // enabling "pause on exceptions" in your JavaScript debugger. throw new Error(message); // eslint-disable-next-line no-empty } catch (e) {} } } /** * @private */ const joinPaths = (paths) => paths.join("/").replace(/\/\/+/g, "/"); /** * @private */ const normalizePathname = (pathname) => pathname.replace(/\/+$/, "").replace(/^\/*/, "/");