git.fiddlerwoaroof.com
src/action-router.js
30db14b1
 import R from 'ramda';
 
28d2ab95
 function pathSplit(path) {
   return path.split('/');
 }
 
30db14b1
 function mostSpecificRouteMatch(match1, match2) {
 
   if (!match1) {
     return match2;
   }
 
28d2ab95
   const paramLength1 = match1.routeParams.length;
   const paramLength2 = match2.routeParams.length;
27fb05ae
   let findWildcard = R.compose(R.findIndex.bind(R, isWildcard), pathSplit);
30db14b1
 
10f5ef83
   let result = (paramLength1 > paramLength2) ? match2 : match1;
28d2ab95
 
   if (paramLength1 === paramLength2) {
 
10f5ef83
     let path1WildcardIdx = findWildcard(match1.path);
     let path2WildcardIdx = findWildcard(match2.path);
 
     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;
 
   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) {
28d2ab95
       if (matcherType === 'exact') {
27fb05ae
         return buildMatch(matchedParams, route);
       } else {
         return mostSpecificRouteMatch(match, buildMatch(matchedParams, route));
       }
41e430d7
     } 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});
27fb05ae
   };
41e430d7
 
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) {
27fb05ae
           return acc;
41e430d7
         } else if (match[0] === ":") {
           return updateWildcard(acc, match, input);
         } else {
           return null;
         }
       };
27fb05ae
       result = R.zip(normMatchPath, normInputPath).reduce(f, {});
30db14b1
     }
 
41e430d7
     return result;
30db14b1
   };
 
 
   let routeParams = extractParams(path);
 
   return {
     type,
     route: {
       routeMatcher,
       path,
       action,
       routeParams,
       extraParams
     }
27fb05ae
   };
30db14b1
 }
 
 function getRouteByPath(pattern, matchers) {
   return matchers.compiledRouteMatchers[pattern];
 }
 
10f5ef83
 function normalizeWildcards(path) {
   let curIdx = 0;
   return path.map((el) => {
     if (isWildcard(el)) {
27fb05ae
       return `:wildcard${curIdx}`;
10f5ef83
     } else {
       return el;
     }
   });
 }
 
 function routeAlreadyExists(compiledRouteMatchers, path) {
27fb05ae
   let result = compiledRouteMatchers.hasOwnProperty(path);
10f5ef83
 
   if (!result) {
     const normalizingSplit = R.compose(normalizeWildcards, pathSplit);
     const pathParts = normalizingSplit(path);
 
     for (const otherPath of Object.keys(compiledRouteMatchers)) {
       const otherPathParts = normalizingSplit(otherPath);
       if (R.equals(pathParts, otherPathParts)) {
27fb05ae
         throw new Error(`invalid routing configuration — route ${path} overlaps with route ${otherPath}`);
10f5ef83
       }
     }
   }
 
   return result;
 }
 
30db14b1
 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);
 
10f5ef83
     if (routeAlreadyExists(compiledRouteMatchers, path)) {
30db14b1
       throw new Error("overlapping paths");
     }
 
     compiledRouteMatchers[path] = route;
   }
   return {
     compiledActionMatchers, // { ACTION: [Route] }
27fb05ae
     compiledRouteMatchers  // { PATH: Route }
   };
30db14b1
 }
 
 ///////
 
 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)
27fb05ae
             ? match.extractedParams[name] : match.extraParams[name];
30db14b1
       resultParts.push(val);
     } else {
       resultParts.push(part);
     }
   }
   return resultParts.join('/');
 }
 
 function createActionDispatcher(routesConfig, window) {
   let {compiledActionMatchers, compiledRouteMatchers} = compileRoutes(routesConfig);
 
   let actionDispatcher = {
de8a1429
     currentLocation: null,
 
30db14b1
     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);
         return theStore;
27fb05ae
       };
30db14b1
     },
     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) {
de8a1429
       if (this.currentLocation !== location.pathname) {
         this.currentLocation = location.pathname;
         const match = matchRoute(location, compiledRouteMatchers);
         if(match) {
           const action = constructAction(match);
30db14b1
 
de8a1429
           this.store.dispatch(action);
         }
30db14b1
       }
     },
     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)) {
27fb05ae
       actionDispatcher.receiveAction(action, store);
30db14b1
     }
     return next(action);
   };
 }
 
 export default function installBrowserRouter(routesConfig, window) {
 
   const actionDispatcher = createActionDispatcher(routesConfig, window);
 
   const middleware = buildMiddleware(actionDispatcher);
 
de8a1429
   return {middleware, enhancer: actionDispatcher.enhanceStore, init: actionDispatcher.receiveLocation.bind(actionDispatcher, window.location)};
30db14b1
 }