import R from 'ramda'; function mostSpecificRouteMatch(match1, match2) { if (!match1) { return match2; } const match1ParamLength = match1.routeParams.length; const match2ParamLength = match2.routeParams.length; let result = null; if (match1ParamLength === match2ParamLength) { for (let [segment1, segment2] of R.zip(match1.path.split("/"), match2.path.split("/"))) { if (R.head(segment1) === ":") { result = match2; break; } else if (R.head(segment2) === ":") { result = match1; break; } } } else if (match1ParamLength > match2ParamLength) { result = match2; } else if (match2ParamLength > match1ParamLength) { result = 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) => Object.assign({extractedParams}, route); let match = null; for (const path in matchers) { const {type: matcherType, route} = matchers[path]; const pathMatcher = route.routeMatcher; const matchedParams = pathMatcher(inputPath); if (pathMatcher(inputPath)) { if (matcherType === 'exact') { match = buildMatch(matchedParams, route); break; } else { match = mostSpecificRouteMatch(match, buildMatch(matchedParams, route)); } } } return match; } function mostSpecificActionMatch(match1, match2) { if (!match1) { return match2; } const {extraParams: match1params} = match1; const {extraParams: match2params} = match2; return Object.keys(match1params).length >= Object.keys(match2params).length ? match1 : match2; } // matchers is {action : [routeMatcher]} structure function matchAction(action, matchers) { // match on params in action vs possible actions if more than 1 let match = null; const {type, ...args} = action; const routes = matchers[type]; // 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)) { match = Object.assign({extractedParams: {}}, route); // case 3 break; // most specific } else if (matcherType === "wildcard") { // case 1+2 const unallocatedArgKeys = R.difference(Object.keys(args), Object.keys(route.extraParams)); // if all keys ^ are equal to all keys in route const intersectCount = R.intersection(unallocatedArgKeys, route.routeParams).length; if (intersectCount === route.routeParams.length && intersectCount === unallocatedArgKeys.length) { const extractedParams = R.pick(unallocatedArgKeys, args); match = mostSpecificActionMatch(match, Object.assign({extractedParams}, route)); } } } return match; } function matchesAction(action, matchers) { return !!matchers[action.type]; } function extractParams(path) { const pathParts = path.split("/"); let params = []; for (const part of pathParts) { if (part[0] === ":") { const name = part.slice(1); if (params.indexOf(name) !== -1) { throw new Error("duplicate param"); } params.push(name); } } return params; } function normalizePathParts(path) { const rawPathParts = R.split('/', path); const normalizedPathParts = R.filter(p => p !== "", rawPathParts); return normalizedPathParts; } function makeRoute(path, action, extraParams) { let type = "exact"; if (path.indexOf(":") !== -1) { type = "wildcard"; } const normalizedPathParts = normalizePathParts(path); const routeMatcher = function (inputPath) { const normMatchPath = normalizedPathParts; const normInputPath = normalizePathParts(inputPath); if (R.equals(normalizedPathParts, normInputPath)) { return {}; } const inputLength = normInputPath.length; const matchLength = normMatchPath.length; if (inputLength !== matchLength) { return false; } const f = (acc, [match, input]) => { if (acc === null) { return null; } if (R.head(match) === ":") { acc[match.replace(':', '')] = input; return acc; } else if (match === input) { return acc; } else { return null; } }; return R.reduce(f, {}, R.zip(normMatchPath, normInputPath)) }; let routeParams = extractParams(path); return { type, route: { routeMatcher, path, action, routeParams, extraParams } } } function getRouteByPath(pattern, matchers) { return matchers.compiledRouteMatchers[pattern]; } function compileRoutes(routesConfig) { let compiledActionMatchers = {}; let 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 (compiledRouteMatchers.hasOwnProperty(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) { let parts = match.path.split('/'); let resultParts = []; for (let 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) { let {compiledActionMatchers, compiledRouteMatchers} = compileRoutes(routesConfig); let actionDispatcher = { store: null, activateDispatcher(store) { window.addEventListener('urlchanged', this); this.store = store; }, enhanceStore(nextStoreCreator) { return (reducer, finalInitialState, enhancer) => { let theStore = nextStoreCreator(reducer, finalInitialState, enhancer); this.activateDispatcher(theStore); this.receiveLocation(window.location); 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); }, receiveLocation(location) { const match = matchRoute(location, compiledRouteMatchers); if(match) { const action = constructAction(match); this.store.dispatch(action); } }, receiveAction(action) { let matcher = matchAction(action, compiledActionMatchers); if(matcher) { let path = constructPath(matcher); 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 = buildMiddleware(actionDispatcher); return {middleware, enhancer: actionDispatcher.enhanceStore}; }