import * as R from "ramda"; function pathSplit(path) { return path.split("/"); } function mostSpecificRouteMatch(match1, match2) { if (!match1) { return match2; } const paramLength1 = match1.routeParams.length; const paramLength2 = match2.routeParams.length; const findWildcard = R.compose( R.findIndex(isWildcard), pathSplit ); let result = paramLength1 > paramLength2 ? match2 : match1; if (paramLength1 === paramLength2) { const path1WildcardIdx = findWildcard(match1.path); const path2WildcardIdx = findWildcard(match2.path); result = path1WildcardIdx !== -1 && path1WildcardIdx < path2WildcardIdx ? match2 : match1; } if (result === null) { throw new Error("routes should have been disambiguated at compile time"); } return result; } // do something with routes. function matchRoute(loc, matchers) { const inputPath = loc.pathname; const buildMatch = (extractedParams, route) => ({ extractedParams, ...route }); return R.reduce( (match, [_, { type: matcherType, route }]) => { const { pathMatcher } = route; const matchedParams = pathMatcher(inputPath); if (matchedParams) { return matcherType === 'exact' ? buildMatch(matchedParams, route) : mostSpecificRouteMatch(match, buildMatch(matchedParams, route)); } else { return match; } }, null, R.toPairs(matchers) ); } function mostSpecificActionMatch(match1, match2) { if (!match1) { return match2; } const countExtraParams = ({ extraParams: obj }) => R.keys(obj).length; return countExtraParams(match1) >= countExtraParams(match2) ? match1 : match2; } // matchers is {action : [pathMatcher]} structure function matchAction(action, matchers) { // match on params in action vs possible actions if more than 1 let match = null; const { type: actionType, ...args } = action; const routes = matchers[actionType]; // Specificity: // 1. wildcard(s) / no extra param /route/:id || /route/me // 2. wildcards / exact extra params match with remaining wildcard // 3. no-wildcard / exact extra params match for (const { type: matcherType, route } of routes) { if (matcherType === "exact" && R.equals(route.extraParams, args)) { // case 3 match = { extractedParams: {}, ...route}; break; // most specific } else if (matcherType === "wildcard") { // case 1+2 const unallocatedArgKeys = R.difference( R.keys(args), R.keys(route.extraParams) ); // if all keys ^ are equal to all keys in route const intersectCount = R.intersection(unallocatedArgKeys, route.routeParams).length; const unionCount = R.union(unallocatedArgKeys, route.routeParams).length; if (intersectCount === unionCount) { const extractedParams = R.pick(unallocatedArgKeys, args); match = mostSpecificActionMatch(match, { extractedParams, ...route }); } } } return match; } function matchesAction(action, matchers) { return !!matchers[action.type]; } function isWildcard(segment) { return segment && segment[0] === ":"; } function extractParams(path) { const pathParts = path.split("/"); const params = R.compose( R.map(x => x.substr(1)), R.filter(isWildcard) )(pathParts); if(R.uniq(params).length !== params.length) { throw new Error("duplicate param"); } return params; } function normalizePathParts(path) { const splitAndFilterEmpty = R.compose( R.filter(p => p !== ""), R.split('/') ); return splitAndFilterEmpty(path); } function makeRoute(path, action, extraParams) { const type = (R.includes(':', path) ? 'wildcard': 'exact'); const normalizedPathParts = normalizePathParts(path); const pathMatcher = function(inputPath) { let result = null; const normMatchPath = normalizedPathParts; const normInputPath = normalizePathParts(inputPath); // exact match if (R.equals(normalizedPathParts, normInputPath)) { return {}; } //wildcard match const inputLength = normInputPath.length; const matchLength = normMatchPath.length; if (inputLength === matchLength) { result = R.reduce((extractedValues, [match, input]) => { if (extractedValues === null) { return null; } if (match === input) { return extractedValues; } else if (R.startsWith(":", match)) { const wildcardName = R.replace(':', '', match); return {...extractedValues, [wildcardName]: input}; } else { return null; } }, {}, R.zip(normMatchPath, normInputPath)); } return result; }; let routeParams = extractParams(path); return { type, route: { pathMatcher, path, action, routeParams, extraParams } }; } function normalizeWildcards(path) { let curIdx = 0; return path.map(el => { if (isWildcard(el)) { return `:wildcard${curIdx}`; } else { return el; } }); } function routeAlreadyExists(compiledRouteMatchers, path) { let result = compiledRouteMatchers.hasOwnProperty(path); if (!result) { const normalizingSplit = R.compose( normalizeWildcards, pathSplit ); const pathParts = normalizingSplit(path); for (const otherPath of R.keys(compiledRouteMatchers)) { const otherPathParts = normalizingSplit(otherPath); if (R.equals(pathParts, otherPathParts)) { throw new Error( `invalid routing configuration — route ${path} overlaps with route ${otherPath}` ); } } } return result; } function compileRoutes(routesConfig) { const compiledActionMatchers = {}; const compiledRouteMatchers = {}; for (let [path, action, extraParams] of routesConfig) { if (typeof path !== "string" || typeof action !== "string") { throw new Error( "invalid routing configuration - path and action must both be strings" ); } if (!compiledActionMatchers[action]) { compiledActionMatchers[action] = []; } const route = makeRoute(path, action, extraParams); compiledActionMatchers[action].push(route); if (routeAlreadyExists(compiledRouteMatchers, path)) { throw new Error("overlapping paths"); } compiledRouteMatchers[path] = route; } return { compiledActionMatchers, // { ACTION: [Route] } compiledRouteMatchers // { PATH: Route } }; } function constructAction(match) { return { type: match.action, ...match.extractedParams, ...match.extraParams }; } function constructPath(match) { const parts = match.path.split("/"); const resultParts = []; for (const part of parts) { if (part[0] === ":") { const name = part.slice(1); const val = match.extractedParams.hasOwnProperty(name) ? match.extractedParams[name] : match.extraParams[name]; resultParts.push(val); } else { resultParts.push(part); } } return resultParts.join("/"); } function createActionDispatcher(routesConfig, window) { const { compiledActionMatchers, compiledRouteMatchers } = compileRoutes( routesConfig ); function pathForAction(action) { const match = matchAction(action, compiledActionMatchers); return match ? constructPath(match) : null; } function actionForLocation(location) { const match = matchRoute(location, compiledRouteMatchers); return match ? constructAction(match) : null; } const actionDispatcher = { currentLocation: null, store: null, activateDispatcher(store) { window.addEventListener("urlchanged", this); this.store = store; }, enhanceStore(nextStoreCreator) { const middleware = buildMiddleware(this); return (reducer, finalInitialState, enhancer) => { const theStore = nextStoreCreator(reducer, finalInitialState, enhancer); this.activateDispatcher(theStore); theStore.pathForAction = pathForAction; theStore.dispatch = middleware(theStore)( theStore.dispatch.bind(theStore) ); return theStore; }; }, handleEvent(ev) { if (!this.store) { throw new Error( "You must call activateDispatcher with redux store as argument" ); } const location = ev.detail; this.receiveLocation(location); }, onLocationChanged(newLoc, cb) { if (this.currentLocation !== newLoc) { this.currentLocation = newLoc; cb(); } }, receiveLocation(location) { this.onLocationChanged(location.pathname, () => { const action = actionForLocation(location); if (action) { this.store.dispatch(action); } }); }, receiveAction(action) { const path = pathForAction(action); if (path) { this.onLocationChanged(path, () => { window.history.pushState({}, "", path); }); } }, handlesAction(action) { return matchesAction(action, compiledActionMatchers); } }; actionDispatcher.enhanceStore = actionDispatcher.enhanceStore.bind( actionDispatcher ); return actionDispatcher; } function buildMiddleware(actionDispatcher) { return store => next => action => { if (actionDispatcher.handlesAction(action)) { actionDispatcher.receiveAction(action, store); } return next(action); }; } export default function installBrowserRouter(routesConfig, window) { const actionDispatcher = createActionDispatcher(routesConfig, window); const middleware = x => { //eslint-disable-next-line no-console console.warn( "Using the routedux middleware directly is deprecated, the enhancer now" + " applies it automatically and the middleware is now a no-op that" + " will be removed in later versions." ); return y => y; }; return { middleware, enhancer: actionDispatcher.enhanceStore, init: actionDispatcher.receiveLocation.bind( actionDispatcher, window.location ) }; }