git.fiddlerwoaroof.com
src/action-router.js
30db14b1
 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);
 
41e430d7
   return R.toPairs(matchers).reduce((match, [path,{type: matcherType, route}]) => {
30db14b1
     const pathMatcher = route.routeMatcher;
     const matchedParams = pathMatcher(inputPath);
 
41e430d7
     if (matchedParams) {
       return (matcherType === 'exact')? buildMatch(matchedParams, route) : mostSpecificRouteMatch(match, buildMatch(matchedParams, route));
     } else {
       return match;
30db14b1
     }
 
41e430d7
   }, null);
30db14b1
 }
 
 function mostSpecificActionMatch(match1, match2) {
 
   if (!match1) {
     return match2;
   }
 
41e430d7
   let countExtraParams = ({extraParams: obj}) => Object.keys(obj).length;
   return countExtraParams(match1) >= countExtraParams(match2) ? match1 : match2;
30db14b1
 }
 
 // matchers is {action : [routeMatcher]} structure
 function matchAction(action, matchers) {
   // match on params in action vs possible actions if more than 1
   let match = null;
 
41e430d7
   const {type: actionType, ...args} = action;
   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
 
   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;
41e430d7
       const unionCount = R.union(unallocatedArgKeys, route.routeParams).length;
30db14b1
 
41e430d7
       if (intersectCount === unionCount) {
30db14b1
         const extractedParams = R.pick(unallocatedArgKeys, args);
         match = mostSpecificActionMatch(match, Object.assign({extractedParams}, route));
       }
     }
   }
 
   return match;
 }
 
 function matchesAction(action, matchers) {
   return !!matchers[action.type];
 }
 
41e430d7
 function isWildcard(segment) {
   return segment && segment[0] === ":";
 }
30db14b1
 function extractParams(path) {
   const pathParts = path.split("/");
   let params = [];
 
41e430d7
   for (const part of pathParts.filter(isWildcard)) {
     const name = part.slice(1);
30db14b1
 
41e430d7
     if (params.indexOf(name) !== -1) {
       throw new Error("duplicate param");
30db14b1
     }
41e430d7
 
     params.push(name);
30db14b1
   }
 
   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);
 
41e430d7
   const updateWildcard = (wildcards, match, input) => {
     const wildcardName = match.replace(':', '');
     return Object.assign(wildcards, {[wildcardName]: input});
   }
 
30db14b1
   const routeMatcher = function (inputPath) {
41e430d7
     let result = null;
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) {
       const f = (acc, [match, input]) => {
         if (acc === null) {
           return null;
         }
 
         if(match === input) {
           return acc
         } else if (match[0] === ":") {
           return updateWildcard(acc, match, input);
         } else {
           return null;
         }
       };
       result = R.zip(normMatchPath, normInputPath).reduce(f, {})
30db14b1
     }
 
41e430d7
     return result;
30db14b1
   };
 
 
   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);
4b39d368
         this.receiveLocation(window.location);
30db14b1
         return theStore;
       }
     },
     handleEvent(ev) {
 
       if (!this.store) {
         throw new Error("You must call activateDispatcher with redux store as argument");
       }
 
       const location = ev.detail;
4b39d368
       this.receiveLocation(location);
     },
     receiveLocation(location) {
30db14b1
       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};
 }