git.fiddlerwoaroof.com
Raw Blame History
import * as R from "ramda";

function pathSplit(path) {
  return path.split("/");
}

function mostSpecificRouteMatch(match1, match2) {
  if (!match1) {
    return match2;
  }

  const paramLength1 = match1.routeParams.length;
  const paramLength2 = match2.routeParams.length;

  const findWildcard = R.compose(
    R.findIndex(isWildcard),
    pathSplit
  );

  let result = paramLength1 > paramLength2 ? match2 : match1;

  if (paramLength1 === paramLength2) {
    const path1WildcardIdx = findWildcard(match1.path);
    const path2WildcardIdx = findWildcard(match2.path);

    result =
      path1WildcardIdx !== -1 && path1WildcardIdx < path2WildcardIdx
        ? match2
        : 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) => ({ extractedParams, ...route });

  return R.reduce(
    (match, [_, { type: matcherType, route }]) => {
      const { pathMatcher } = route;
      const matchedParams = pathMatcher(inputPath);

      if (matchedParams) {
        return matcherType === 'exact'
          ? buildMatch(matchedParams, route)
          : mostSpecificRouteMatch(match, buildMatch(matchedParams, route));
      } else {
        return match;
      }
    },
    null,
    R.toPairs(matchers)
  );
}

function mostSpecificActionMatch(match1, match2) {
  if (!match1) {
    return match2;
  }

  const countExtraParams = ({ extraParams: obj }) => R.keys(obj).length;
  return countExtraParams(match1) >= countExtraParams(match2) ? match1 : match2;
}

// matchers is {action : [pathMatcher]} structure
function matchAction(action, matchers) {
  // match on params in action vs possible actions if more than 1
  let match = null;

  const { type: actionType, ...args } = action;
  const routes = matchers[actionType];

  // 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)) {
      // case 3
      match = { extractedParams: {}, ...route};
      break; // most specific
    } else if (matcherType === "wildcard") {
      // case 1+2

      const unallocatedArgKeys = R.difference(
        R.keys(args),
        R.keys(route.extraParams)
      );
      // if all keys ^ are equal to all keys in route
      const intersectCount = R.intersection(unallocatedArgKeys, route.routeParams).length;
      const unionCount = R.union(unallocatedArgKeys, route.routeParams).length;

      if (intersectCount === unionCount) {
        const extractedParams = R.pick(unallocatedArgKeys, args);
        match = mostSpecificActionMatch(match, { extractedParams, ...route });
      }
    }
  }

  return match;
}

function matchesAction(action, matchers) {
  return !!matchers[action.type];
}

function isWildcard(segment) {
  return segment && segment[0] === ":";
}

function extractParams(path) {
  const pathParts = path.split("/");

  const params = R.compose(
    R.map(x => x.substr(1)),
    R.filter(isWildcard)
  )(pathParts);

  if(R.uniq(params).length !== params.length) {
    throw new Error("duplicate param");
  }

  return params;
}

function normalizePathParts(path) {
  const splitAndFilterEmpty = R.compose(
    R.filter(p => p !== ""),
    R.split('/')
  );

  return splitAndFilterEmpty(path);
}

function makeRoute(path, action, extraParams) {
  const type = (R.includes(':', path) ? 'wildcard': 'exact');

  const normalizedPathParts = normalizePathParts(path);

  const pathMatcher = function(inputPath) {
    let result = null;

    const normMatchPath = normalizedPathParts;
    const normInputPath = normalizePathParts(inputPath);

    // exact match
    if (R.equals(normalizedPathParts, normInputPath)) {
      return {};
    }

    //wildcard match
    const inputLength = normInputPath.length;
    const matchLength = normMatchPath.length;

    if (inputLength === matchLength) {
      result = R.reduce((extractedValues, [match, input]) => {
        if (extractedValues === null) {
          return null;
        }

        if (match === input) {
          return extractedValues;
        } else if (R.startsWith(":", match)) {
          const wildcardName = R.replace(':', '', match);
          return {...extractedValues, [wildcardName]: input};
        } else {
          return null;
        }
      }, {}, R.zip(normMatchPath, normInputPath));
    }

    return result;
  };

  let routeParams = extractParams(path);

  return {
    type,
    route: {
      pathMatcher,
      path,
      action,
      routeParams,
      extraParams
    }
  };
}

function normalizeWildcards(path) {
  let curIdx = 0;
  //todo curIdx doesn't increment
  return path.map(el => {
    if (isWildcard(el)) {
      return `:wildcard${curIdx}`;
    } else {
      return el;
    }
  });
}

function routeAlreadyExists(compiledRouteMatchers, path) {
  let result = Object.prototype.hasOwnProperty.call(compiledRouteMatchers, path);

  if (!result) {
    const normalizingSplit = R.compose(
      normalizeWildcards,
      pathSplit
    );
    const pathParts = normalizingSplit(path);

    for (const otherPath of R.keys(compiledRouteMatchers)) {
      const otherPathParts = normalizingSplit(otherPath);
      if (R.equals(pathParts, otherPathParts)) {
        throw new Error(
          `invalid routing configuration — route ${path} overlaps with route ${otherPath}`
        );
      }
    }
  }

  return result;
}

function compileRoutes(routesConfig) {
  const compiledActionMatchers = {};
  const 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 (routeAlreadyExists(compiledRouteMatchers, 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) {
  const parts = match.path.split("/");
  const resultParts = [];

  for (const part of parts) {
    if (part[0] === ":") {
      const name = part.slice(1);
      const val = Object.prototype.hasOwnProperty.call(match.extractedParams, name)
        ? match.extractedParams[name]
        : match.extraParams[name];
      resultParts.push(val);
    } else {
      resultParts.push(part);
    }
  }
  return resultParts.join("/");
}

function createActionDispatcher(routesConfig, window) {
  const { compiledActionMatchers, compiledRouteMatchers } = compileRoutes(
    routesConfig
  );

  function pathForAction(action) {
    const match = matchAction(action, compiledActionMatchers);
    return match ? constructPath(match) : null;
  }

  function actionForLocation(location) {
    const match = matchRoute(location, compiledRouteMatchers);
    return match ? constructAction(match) : null;
  }

  let actionListeners = [];
  let currentPath = null;

  function ifPathChanged(newPath, cb) {
    if (currentPath !== newPath) {
      currentPath = newPath;
      cb();
    }
  }

  const actionDispatcher = {

    pathForAction,

    //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);
      }
    },

    //needed for window event listener
    handleEvent(ev) {
      const location = ev.detail;
      this.receiveLocation(location);
    },

    receiveLocation(location) {
      ifPathChanged(location.pathname, () => {

        const action = actionForLocation(location);

        if (action) {
          actionListeners.forEach(cb => cb(action));
        }
      });
    },

    // can this be simplified to get rid of fundamental action model?
    receiveAction(action, fireCallbacks = false) {
      const newPath = matchesAction(action, compiledActionMatchers)
                    ? pathForAction(action)
                    : null;

      if (newPath) {
        ifPathChanged(newPath, () => {
          window.history.pushState({}, "", newPath);
          if(fireCallbacks) {
            actionListeners.forEach(cb => cb(action));
          }
        });
      }
    },

  };

  window.addEventListener("urlchanged", actionDispatcher);

  return actionDispatcher;
}



export {createActionDispatcher};