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 |
}
|