git.fiddlerwoaroof.com
Raw Blame History
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);

  let match = null;
  for (const path in matchers) {
    const {type: matcherType, route} = matchers[path];
    const pathMatcher = route.routeMatcher;

    const matchedParams = pathMatcher(inputPath);

    if (pathMatcher(inputPath)) {
      if (matcherType === 'exact') {
        match = buildMatch(matchedParams, route);
        break;
      } else {
        match = mostSpecificRouteMatch(match, buildMatch(matchedParams, route));
      }
    }
  }

  return match;

}

function mostSpecificActionMatch(match1, match2) {

  if (!match1) {
    return match2;
  }

  const {extraParams: match1params} = match1;
  const {extraParams: match2params} = match2;
  return Object.keys(match1params).length >= Object.keys(match2params).length ? match1 : match2;
}

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

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

  // 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;

      if (intersectCount === route.routeParams.length && intersectCount === unallocatedArgKeys.length) {
        const extractedParams = R.pick(unallocatedArgKeys, args);
        match = mostSpecificActionMatch(match, Object.assign({extractedParams}, route));
      }
    }
  }

  return match;
}

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

function extractParams(path) {
  const pathParts = path.split("/");
  let params = [];

  for (const part of pathParts) {
    if (part[0] === ":") {
      const name = part.slice(1);

      if (params.indexOf(name) !== -1) {
        throw new Error("duplicate param");
      }

      params.push(name);
    }
  }

  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);

  const routeMatcher = function (inputPath) {
    const normMatchPath = normalizedPathParts;
    const normInputPath = normalizePathParts(inputPath);

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

    const inputLength = normInputPath.length;
    const matchLength = normMatchPath.length;

    if (inputLength !== matchLength) {
      return false;
    }

    const f = (acc, [match, input]) => {
      if (acc === null) {
        return null;
      }
      if (R.head(match) === ":") {
        acc[match.replace(':', '')] = input;
        return acc;
      } else if (match === input) {
        return acc;
      } else {
        return null;
      }
    };

    return R.reduce(f, {}, R.zip(normMatchPath, normInputPath))
  };


  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);
        this.receiveLocation(window.location);
        return theStore;
      }
    },
    handleEvent(ev) {

      if (!this.store) {
        throw new Error("You must call activateDispatcher with redux store as argument");
      }

      const location = ev.detail;
      this.receiveLocation(location);
    },
    receiveLocation(location) {
      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};
}