git.fiddlerwoaroof.com
src/action-router.js
84348047
 import * as R from "ramda";
30db14b1
 
28d2ab95
 function pathSplit(path) {
84348047
   return path.split("/");
28d2ab95
 }
 
30db14b1
 function mostSpecificRouteMatch(match1, match2) {
   if (!match1) {
     return match2;
   }
 
28d2ab95
   const paramLength1 = match1.routeParams.length;
   const paramLength2 = match2.routeParams.length;
f62307c9
 
72e96b4d
   const findWildcard = R.compose(
f62307c9
     R.findIndex(isWildcard),
84348047
     pathSplit
   );
30db14b1
 
84348047
   let result = paramLength1 > paramLength2 ? match2 : match1;
28d2ab95
 
   if (paramLength1 === paramLength2) {
72e96b4d
     const path1WildcardIdx = findWildcard(match1.path);
     const path2WildcardIdx = findWildcard(match2.path);
10f5ef83
 
84348047
     result =
       path1WildcardIdx !== -1 && path1WildcardIdx < path2WildcardIdx
         ? match2
         : match1;
30db14b1
   }
 
   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;
 
f62307c9
   const buildMatch = (extractedParams, route) => ({ extractedParams, ...route });
30db14b1
 
dae2ec8a
   return R.reduce(
7a0c8d3f
     (match, [_, { type: matcherType, route }]) => {
dae2ec8a
       const { pathMatcher } = route;
84348047
       const matchedParams = pathMatcher(inputPath);
30db14b1
 
84348047
       if (matchedParams) {
dae2ec8a
         return matcherType === 'exact'
           ? buildMatch(matchedParams, route)
           : mostSpecificRouteMatch(match, buildMatch(matchedParams, route));
27fb05ae
       } else {
84348047
         return match;
27fb05ae
       }
84348047
     },
dae2ec8a
     null,
     R.toPairs(matchers)
84348047
   );
30db14b1
 }
 
 function mostSpecificActionMatch(match1, match2) {
   if (!match1) {
     return match2;
   }
 
72e96b4d
   const countExtraParams = ({ extraParams: obj }) => R.keys(obj).length;
41e430d7
   return countExtraParams(match1) >= countExtraParams(match2) ? match1 : match2;
30db14b1
 }
 
dae2ec8a
 // matchers is {action : [pathMatcher]} structure
30db14b1
 function matchAction(action, matchers) {
   // match on params in action vs possible actions if more than 1
   let match = null;
 
84348047
   const { type: actionType, ...args } = action;
41e430d7
   const routes = matchers[actionType];
30db14b1
 
   // 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
 
84348047
   for (const { type: matcherType, route } of routes) {
30db14b1
     if (matcherType === "exact" && R.equals(route.extraParams, args)) {
       // case 3
dae2ec8a
       match = { extractedParams: {}, ...route};
30db14b1
       break; // most specific
     } else if (matcherType === "wildcard") {
       // case 1+2
 
84348047
       const unallocatedArgKeys = R.difference(
f62307c9
         R.keys(args),
         R.keys(route.extraParams)
84348047
       );
30db14b1
       // if all keys ^ are equal to all keys in route
dae2ec8a
       const intersectCount = R.intersection(unallocatedArgKeys, route.routeParams).length;
41e430d7
       const unionCount = R.union(unallocatedArgKeys, route.routeParams).length;
30db14b1
 
41e430d7
       if (intersectCount === unionCount) {
30db14b1
         const extractedParams = R.pick(unallocatedArgKeys, args);
dae2ec8a
         match = mostSpecificActionMatch(match, { extractedParams, ...route });
30db14b1
       }
     }
   }
 
   return match;
 }
 
 function matchesAction(action, matchers) {
   return !!matchers[action.type];
 }
 
41e430d7
 function isWildcard(segment) {
   return segment && segment[0] === ":";
 }
478c0afb
 
30db14b1
 function extractParams(path) {
   const pathParts = path.split("/");
 
dae2ec8a
   const params = R.compose(
     R.map(x => x.substr(1)),
     R.filter(isWildcard)
   )(pathParts);
30db14b1
 
dae2ec8a
   if(R.uniq(params).length !== params.length) {
     throw new Error("duplicate param");
30db14b1
   }
dae2ec8a
 
30db14b1
   return params;
 }
 
 function normalizePathParts(path) {
dae2ec8a
   const splitAndFilterEmpty = R.compose(
     R.filter(p => p !== ""),
     R.split('/')
f62307c9
   );
 
   return splitAndFilterEmpty(path);
30db14b1
 }
 
 function makeRoute(path, action, extraParams) {
f62307c9
   const type = (R.includes(':', path) ? 'wildcard': 'exact');
30db14b1
 
   const normalizedPathParts = normalizePathParts(path);
 
dae2ec8a
   const pathMatcher = function(inputPath) {
41e430d7
     let result = null;
f62307c9
 
30db14b1
     const normMatchPath = normalizedPathParts;
     const normInputPath = normalizePathParts(inputPath);
 
41e430d7
     // exact match
30db14b1
     if (R.equals(normalizedPathParts, normInputPath)) {
       return {};
     }
 
41e430d7
     //wildcard match
30db14b1
     const inputLength = normInputPath.length;
     const matchLength = normMatchPath.length;
 
41e430d7
     if (inputLength === matchLength) {
dae2ec8a
       result = R.reduce((extractedValues, [match, input]) => {
         if (extractedValues === null) {
41e430d7
           return null;
         }
 
84348047
         if (match === input) {
dae2ec8a
           return extractedValues;
         } else if (R.startsWith(":", match)) {
           const wildcardName = R.replace(':', '', match);
           return {...extractedValues, [wildcardName]: input};
41e430d7
         } else {
           return null;
         }
dae2ec8a
       }, {}, R.zip(normMatchPath, normInputPath));
30db14b1
     }
 
41e430d7
     return result;
30db14b1
   };
 
   let routeParams = extractParams(path);
 
   return {
     type,
     route: {
dae2ec8a
       pathMatcher,
30db14b1
       path,
       action,
       routeParams,
       extraParams
     }
27fb05ae
   };
30db14b1
 }
 
10f5ef83
 function normalizeWildcards(path) {
   let curIdx = 0;
84348047
   return path.map(el => {
10f5ef83
     if (isWildcard(el)) {
27fb05ae
       return `:wildcard${curIdx}`;
10f5ef83
     } else {
       return el;
     }
   });
 }
 
 function routeAlreadyExists(compiledRouteMatchers, path) {
27fb05ae
   let result = compiledRouteMatchers.hasOwnProperty(path);
10f5ef83
 
   if (!result) {
84348047
     const normalizingSplit = R.compose(
       normalizeWildcards,
       pathSplit
     );
10f5ef83
     const pathParts = normalizingSplit(path);
 
f62307c9
     for (const otherPath of R.keys(compiledRouteMatchers)) {
10f5ef83
       const otherPathParts = normalizingSplit(otherPath);
       if (R.equals(pathParts, otherPathParts)) {
84348047
         throw new Error(
           `invalid routing configuration — route ${path} overlaps with route ${otherPath}`
         );
10f5ef83
       }
     }
   }
 
   return result;
 }
 
30db14b1
 function compileRoutes(routesConfig) {
f62307c9
   const compiledActionMatchers = {};
   const compiledRouteMatchers = {};
30db14b1
 
   for (let [path, action, extraParams] of routesConfig) {
84348047
     if (typeof path !== "string" || typeof action !== "string") {
       throw new Error(
         "invalid routing configuration - path and action must both be strings"
       );
30db14b1
     }
 
     if (!compiledActionMatchers[action]) {
       compiledActionMatchers[action] = [];
     }
 
     const route = makeRoute(path, action, extraParams);
     compiledActionMatchers[action].push(route);
 
10f5ef83
     if (routeAlreadyExists(compiledRouteMatchers, path)) {
30db14b1
       throw new Error("overlapping paths");
     }
 
     compiledRouteMatchers[path] = route;
   }
   return {
     compiledActionMatchers, // { ACTION: [Route] }
84348047
     compiledRouteMatchers // { PATH: Route }
27fb05ae
   };
30db14b1
 }
 
 function constructAction(match) {
84348047
   return { type: match.action, ...match.extractedParams, ...match.extraParams };
30db14b1
 }
 
 function constructPath(match) {
72e96b4d
   const parts = match.path.split("/");
   const resultParts = [];
30db14b1
 
72e96b4d
   for (const part of parts) {
30db14b1
     if (part[0] === ":") {
       const name = part.slice(1);
       const val = match.extractedParams.hasOwnProperty(name)
84348047
         ? match.extractedParams[name]
         : match.extraParams[name];
30db14b1
       resultParts.push(val);
     } else {
       resultParts.push(part);
     }
   }
84348047
   return resultParts.join("/");
30db14b1
 }
 
 function createActionDispatcher(routesConfig, window) {
72e96b4d
   const { compiledActionMatchers, compiledRouteMatchers } = compileRoutes(
84348047
     routesConfig
   );
30db14b1
 
478c0afb
   function pathForAction(action) {
     const match = matchAction(action, compiledActionMatchers);
     return match ? constructPath(match) : null;
   }
 
f62307c9
   function actionForLocation(location) {
     const match = matchRoute(location, compiledRouteMatchers);
     return match ? constructAction(match) : null;
   }
 
72e96b4d
   const actionDispatcher = {
de8a1429
     currentLocation: null,
30db14b1
     store: null,
f62307c9
 
30db14b1
     activateDispatcher(store) {
84348047
       window.addEventListener("urlchanged", this);
30db14b1
       this.store = store;
     },
f62307c9
 
30db14b1
     enhanceStore(nextStoreCreator) {
72e96b4d
       const middleware = buildMiddleware(this);
f62307c9
 
30db14b1
       return (reducer, finalInitialState, enhancer) => {
72e96b4d
         const theStore = nextStoreCreator(reducer, finalInitialState, enhancer);
f62307c9
 
30db14b1
         this.activateDispatcher(theStore);
f62307c9
 
478c0afb
         theStore.pathForAction = pathForAction;
f62307c9
 
84348047
         theStore.dispatch = middleware(theStore)(
           theStore.dispatch.bind(theStore)
         );
30db14b1
         return theStore;
27fb05ae
       };
30db14b1
     },
f62307c9
 
30db14b1
     handleEvent(ev) {
       if (!this.store) {
84348047
         throw new Error(
           "You must call activateDispatcher with redux store as argument"
         );
30db14b1
       }
 
       const location = ev.detail;
4b39d368
       this.receiveLocation(location);
     },
7b1d07fc
 
     onLocationChanged(newLoc, cb) {
       if (this.currentLocation !== newLoc) {
         this.currentLocation = newLoc;
f62307c9
         cb();
7b1d07fc
       }
     },
 
4b39d368
     receiveLocation(location) {
7b1d07fc
       this.onLocationChanged(location.pathname, () => {
30db14b1
 
f62307c9
         const action = actionForLocation(location);
 
         if (action) {
de8a1429
           this.store.dispatch(action);
         }
7b1d07fc
       });
30db14b1
     },
7b1d07fc
 
30db14b1
     receiveAction(action) {
f62307c9
       const path = pathForAction(action);
 
       if (path) {
7b1d07fc
         this.onLocationChanged(path, () => {
84348047
           window.history.pushState({}, "", path);
7b1d07fc
         });
30db14b1
       }
     },
7b1d07fc
 
30db14b1
     handlesAction(action) {
       return matchesAction(action, compiledActionMatchers);
     }
   };
 
84348047
   actionDispatcher.enhanceStore = actionDispatcher.enhanceStore.bind(
     actionDispatcher
   );
30db14b1
 
   return actionDispatcher;
 }
 
 function buildMiddleware(actionDispatcher) {
   return store => next => action => {
     if (actionDispatcher.handlesAction(action)) {
27fb05ae
       actionDispatcher.receiveAction(action, store);
30db14b1
     }
     return next(action);
   };
 }
 
 export default function installBrowserRouter(routesConfig, window) {
   const actionDispatcher = createActionDispatcher(routesConfig, window);
 
bab394ca
   const middleware = x => {
7a0c8d3f
     //eslint-disable-next-line no-console
bab394ca
     console.warn(
7a0c8d3f
       "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."
bab394ca
     );
     return y => y;
   };
30db14b1
 
84348047
   return {
     middleware,
     enhancer: actionDispatcher.enhanceStore,
     init: actionDispatcher.receiveLocation.bind(
       actionDispatcher,
       window.location
     )
   };
30db14b1
 }