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;
77ace510
   //todo curIdx doesn't increment
84348047
   return path.map(el => {
10f5ef83
     if (isWildcard(el)) {
27fb05ae
       return `:wildcard${curIdx}`;
10f5ef83
     } else {
       return el;
     }
   });
 }
 
 function routeAlreadyExists(compiledRouteMatchers, path) {
5c5f8ac3
   let result = Object.prototype.hasOwnProperty.call(compiledRouteMatchers, 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);
5c5f8ac3
       const val = Object.prototype.hasOwnProperty.call(match.extractedParams, 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;
   }
 
77ace510
   let actionListeners = [];
   let currentPath = null;
f62307c9
 
77ace510
   function ifPathChanged(newPath, cb) {
     if (currentPath !== newPath) {
       currentPath = newPath;
       cb();
     }
   }
f62307c9
 
77ace510
   const actionDispatcher = {
f62307c9
 
77ace510
     pathForAction,
f62307c9
 
77ace510
     //hook for everything to get action on route change
     addActionListener(cb) {
       actionListeners.push(cb);
       return () => {
         const index = R.findIndex(x => x === cb, actionListeners);
         actionListeners = R.remove(index, 1, actionListeners);
30db14b1
       }
77ace510
     },
30db14b1
 
77ace510
     //needed for window event listener
     handleEvent(ev) {
30db14b1
       const location = ev.detail;
4b39d368
       this.receiveLocation(location);
     },
7b1d07fc
 
4b39d368
     receiveLocation(location) {
77ace510
       ifPathChanged(location.pathname, () => {
30db14b1
 
f62307c9
         const action = actionForLocation(location);
 
         if (action) {
77ace510
           actionListeners.forEach(cb => cb(action));
de8a1429
         }
7b1d07fc
       });
30db14b1
     },
7b1d07fc
 
77ace510
     // can this be simplified to get rid of fundamental action model?
df6a63f6
     receiveAction(action, fireCallbacks = false) {
77ace510
       const newPath = matchesAction(action, compiledActionMatchers)
                     ? pathForAction(action)
                     : null;
f62307c9
 
77ace510
       if (newPath) {
         ifPathChanged(newPath, () => {
           window.history.pushState({}, "", newPath);
df6a63f6
           if(fireCallbacks) {
             actionListeners.forEach(cb => cb(action));
           }
7b1d07fc
         });
30db14b1
       }
     },
7b1d07fc
 
30db14b1
   };
 
77ace510
   window.addEventListener("urlchanged", actionDispatcher);
 
30db14b1
   return actionDispatcher;
 }
 
 
e49cba82
 
 export {createActionDispatcher};