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